diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh
index ce2ce81831..87ce6541aa 100755
--- a/.claude/hooks/allow-fileops-in-tmp.sh
+++ b/.claude/hooks/allow-fileops-in-tmp.sh
@@ -1,15 +1,31 @@
#!/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.
+# PreToolUse allowance for scratch file ops: auto-allow `mkdir` / `cp` / `mv` / `touch` /
+# `chmod` whose every path operand resolves inside one of the roots `path_class` recognizes —
+# under /tmp, or inside a git working tree under $HOME — and `tar` / `unzip` confined to /tmp.
+# Anything else makes no decision (exit 0) and falls back to the normal permission flow, except
+# for `mv` and `chmod`: those get an explicit `ask`, the only prompt they get (see
+# lib-guarded-verb.sh).
#
-# 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.
+# The command is read one segment at a time, so chaining and line breaks carry no weight of
+# their own: `cd /tmp/scratch && mv /tmp/a /tmp/b` is proved on the operands of the `mv`. A
+# decision covers the whole command line, so `allow` is emitted only when every segment is one
+# of these verbs proved here or a `cd` that resolved, AND exactly one of them writes (see the
+# gate at the foot of this file — an earlier write can change what a later operand means). A
+# line that mixes a proven op with some other command makes no decision instead and leaves that
+# line to the normal permission flow, rather than waving an unexamined command through with it.
+#
+# This is a hook rather than an allow rule because 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.
+#
+# One operation may not straddle two roots, sources included, and a sibling checkout is a
+# different root — `path_class` names the git tree, not just its kind. A copy out of a checkout
+# into /tmp would be a read-exfiltration path around the `Read(**/secrets/**)` / `Read(**/*.pem)`
+# deny rules, since the content lands where `Read(/tmp/**)` allows it to be read back, and one
+# out of a repo the Read tool is not confined to would do the same for that repo. Keeping every
+# operand of one operation inside a single root closes both without restating those rules here.
+# The checkout root itself is what makes an in-repo `mv` or `chmod` auto-allowable: deleting a
+# file there has never prompted, and moving or chmod-ing one is not the graver act.
#
# 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
@@ -17,12 +33,19 @@
# 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` keep the stricter rule — /tmp only, and absolute operands only — because
+# their positional grammar makes a bare word ambiguous: `tar P -xf ...` is --absolute-names,
+# not a file named P, and resolving it as a path would put an option in a root and allow it.
+# The other five take relative operands, resolved against the working directory that `cd`
+# tracking maintains, since for those a bare word really is a path (a GNU option starts with
+# `-`, and the option allowlist below rejects the ones that would change symlink handling).
+#
# `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.
+# Extraction additionally requires an explicit destination under /tmp, or a working directory
+# 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
@@ -31,6 +54,7 @@
#
# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env.
set -uo pipefail
+. "${BASH_SOURCE[0]%/*}/lib-guarded-verb.sh"
input=$(cat)
command -v jq >/dev/null 2>&1 || exit 0
@@ -38,25 +62,62 @@ 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
+# Every bail-out below goes through `defer`: `mv` and `chmod` prompt from here, since no rule
+# covers them, while the other verbs stay silent and leave the decision to the normal flow.
+guarded=0
+for verb in mv chmod; do
+ runs_verb "$verb" "$cmd" && { guarded=1; break; }
+done
+defer() {
+ [ "$guarded" = 1 ] && decide ask "$1"
+ exit 0
+}
-read -r -a toks <<< "$cmd"
+has_substitution "$cmd" && defer "command substitution in the command line"
-# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp.
+# 0 iff the token is a literal path this hook may reason about. A glob never auto-allows: bash
+# expands it only after the 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, and `cp` and `chmod` follow a command-line symlink, so that is a write to the target.
+# (guard-rm-outside-tmp.sh can allow globs because `rm` unlinks the symlink rather than following
+# it.) The charset holds none of the characters bash uses for quoting, expansion or separation.
+literal_path() {
+ case "$1" in *[*?[]*) return 1 ;; esac
+ [ -z "$(printf '%s' "$1" | tr -d 'A-Za-z0-9._/-')" ]
+}
+
+# Prints the root class of a path token, then the path it resolved to on a second line,
+# resolving a relative one against the tracked working directory. Fails, printing nothing,
+# when the token is unsafe to reason about or lands outside every root.
+operand_class() {
+ local t="$1" canon alt cls alt_cls=""
+ literal_path "$t" || return 1
+ case "$t" in
+ /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;;
+ *) # A `cd` may fail at runtime and leave the command where it started, so a relative
+ # operand has to land in the same root either way.
+ [ -n "$seg_cwd" ] || return 1
+ canon=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null)
+ if [ -n "$alt_cwd" ]; then
+ alt=$(realpath -m -- "$alt_cwd/$t" 2>/dev/null)
+ [ -n "$alt" ] || return 1
+ alt_cls=$(path_class "$alt") || return 1
+ fi
+ ;;
+ esac
+ [ -n "$canon" ] || return 1
+ cls=$(path_class "$canon") || return 1
+ [ -n "$alt_cls" ] && [ "$alt_cls" != "$cls" ] && return 1
+ # Class and resolved path together: a caller runs this in a command substitution, so a global
+ # set here would be set in that subshell and lost.
+ printf '%s\n%s' "$cls" "$canon"
+}
+
+# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. The archive
+# parser's stricter check; everything else goes through operand_class.
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.
+ literal_path "$t" || return 1
case "$t" in /*) ;; *) return 1 ;; esac
canon=$(realpath -m -- "$t" 2>/dev/null)
[ -n "$canon" ] || return 1
@@ -65,34 +126,16 @@ under_tmp() {
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]}"
+# Proves one `tar` / `unzip` segment ($1 = the verb), whose tokens are in SEG_TOKS.
+check_archive_segment() {
+ local verb="$1" ok_flags val_flags t flags val
+ local saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0 i=1
+ case "$verb" in
+ tar) ok_flags='xctzjJavfC'; val_flags='fC' ;;
+ unzip) ok_flags='oqnljvd'; val_flags='d' ;;
+ esac
+ while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do
+ t="${SEG_TOKS[$i]}"
i=$((i + 1))
if [ "$end_opts" = 0 ]; then
[ "$t" = "--" ] && { end_opts=1; continue; }
@@ -101,18 +144,18 @@ if [ -n "${ok_flags:-}" ]; then
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
+ [ -n "$(printf '%s' "$flags" | tr -d "$ok_flags")" ] && defer "unrecognized option \`$t\`"
case "$flags" in *x*) extracting=1 ;; esac
- case "${toks[0]}$flags" in unzip*[lv]*) listing=1 ;; esac
+ case "$verb$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%?}" in *[$val_flags]*) defer "ambiguous option bundle \`$t\`" ;; esac
case "${flags: -1}" in
[$val_flags])
- val="${toks[$i]:-}"
+ val="${SEG_TOKS[$i]:-}"
i=$((i + 1))
- [ -n "$val" ] || exit 0
- under_tmp "$val" || exit 0
+ [ -n "$val" ] || defer "option \`$t\` has no value"
+ under_tmp "$val" || defer "\`$val\` is outside /tmp"
case "${flags: -1}" in
f) saw_archive=1 ;;
C | d) saw_dest=1 ;;
@@ -126,54 +169,153 @@ if [ -n "${ok_flags:-}" ]; then
# 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
+ under_tmp "$t" || defer "\`$t\` is outside /tmp"
+ [ "$verb" = "unzip" ] && saw_archive=1
done
- [ "$saw_archive" = 1 ] || exit 0 # tar without -f reads a tape/stdin; unzip needs an archive
+ # tar without -f reads a tape/stdin; unzip needs an archive
+ [ "$saw_archive" = 1 ] || defer "no archive operand"
# 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
+ if [ "$extracting" = 1 ] || { [ "$verb" = "unzip" ] && [ "$listing" = 0 ]; }; then
+ # An extraction with no destination lands in the working directory. Word splitting cannot
+ # tell a `cd` inside a quoted string from one the shell runs, and believing a false one
+ # would put an archive's members in the checkout, so once any `cd` is in the line only an
+ # explicit destination will do.
+ [ "$saw_dest" = 1 ] \
+ || { [ "$saw_cd" = 0 ] && [ -n "$seg_cwd" ] && under_tmp "$seg_cwd"; } \
+ || defer "extraction target is outside /tmp"
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))
+# Proves one `mkdir` / `cp` / `mv` / `touch` / `chmod` segment ($1 = the verb), whose tokens
+# are in SEG_TOKS.
+check_fileops_segment() {
+ local verb="$1" takes_mode ok_opts t cls resolved seen_class=""
+ local path_operand=0 seen_mode=0 end_opts=0 i=1 rel_operand=0
+ local -a ops=()
+ # 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 "$verb" 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
+ esac
+ while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do
+ t="${SEG_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
+ 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")" ] && defer "unrecognized option \`$t\`"
+ 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
+ # 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]*)*$' || defer "unrecognized mode \`$t\`" ;;
+ esac
+ seen_mode=1
+ continue
+ fi
- under_tmp "$t" || exit 0
- path_operand=1
+ resolved=$(operand_class "$t") || defer "\`$t\` is outside /tmp and not inside a git checkout in \$HOME"
+ cls="${resolved%%$'\n'*}"
+ # Every operand of one operation stays in one root: see the exfiltration note above.
+ [ -n "$seen_class" ] && [ "$cls" != "$seen_class" ] && defer "\`$t\` puts this $verb across two roots"
+ seen_class="$cls"
+ ops+=("${resolved#*$'\n'}")
+ case "$t" in /*) ;; *) rel_operand=1 ;; esac
+ path_operand=1
+ done
+
+ [ "$path_operand" = 1 ] || defer "no path operand"
+
+ # In directory form the command writes a path it does not name: `cp x dir` writes `dir/x`,
+ # and `cp` follows that child when it is a symlink — this checkout is full of them, every
+ # `*_ee.rs` pointing into the sibling EE repo. Deriving that child would mean reproducing
+ # which name the tool picks (the operand as written, not as resolved — a symlinked source
+ # keeps its own name) and how deep `-r` recurses. The form is left unproved instead.
+ case "$verb" in
+ cp | mv)
+ [ "${#ops[@]}" -ge 2 ] || return 0
+ # Whether the destination is an existing directory is itself a question about which of
+ # the two candidate working directories the command ran in, and only one of them is in
+ # `ops`. A `cd` that fails at runtime would otherwise let the form through: the
+ # destination resolved against the directory the command never reached is some path that
+ # does not exist, while the one it actually ran in is a directory full of symlinks.
+ [ -n "$alt_cwd" ] && [ "$rel_operand" = 1 ] \
+ && defer "a relative operand after a \`cd\` lands in one of two directories"
+ [ -d "${ops[-1]}" ] \
+ && defer "\`${ops[-1]}\` already exists as a directory, so this $verb writes a path it does not name"
+ ;;
+ esac
+}
+
+split_segments "$cmd"
+seg_cwd="${cwd:-$PWD}"
+alt_cwd="" # where a `cd` that failed would have left the command
+saw_cd=0 # a `cd` moved the working directory somewhere
+proved=0 # how many ops came out inside a single root
+only_ours=1 # ... and nothing else shares the command line
+
+for seg in "${SEGMENTS[@]}"; do
+ segment_tokens "$seg"
+ case "${SEG_TOKS[0]:-}" in
+ "") continue ;;
+ mkdir | cp | mv | touch | chmod)
+ check_fileops_segment "${SEG_TOKS[0]}"
+ proved=$((proved + 1))
+ continue
+ ;;
+ tar | unzip)
+ check_archive_segment "${SEG_TOKS[0]}"
+ proved=$((proved + 1))
+ continue
+ ;;
+ cd)
+ # A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative
+ # operand points, to one of the two candidates `apply_cd` describes.
+ if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then
+ alt_cwd="$seg_cwd"
+ seg_cwd="$new_cwd"
+ else
+ # Not the harmless segment an allow assumes: whatever this guard could not account for
+ # may be a redirect, and a redirect writes. Leave the line to the normal flow.
+ seg_cwd="" alt_cwd=""
+ only_ours=0
+ fi
+ saw_cd=1
+ continue
+ ;;
+ esac
+ # Some other command shares the line. If an `mv` or `chmod` runs inside it after all — behind
+ # a wrapper, an env prefix or a path — this hook cannot say what it writes to.
+ for verb in mv chmod; do
+ segment_runs_verb "$verb" "$seg" && defer "$verb is not the leading command word in \`$seg\`"
+ done
+ only_ours=0
done
-[ "$path_operand" = 1 ] || exit 0
-allow "every path operand is under /tmp"
+# Exactly one write per line. Each segment is proved against the filesystem as it stands now,
+# and an earlier write can change what a later operand means: `cp -r /tmp/tree /tmp/live` that
+# recreates a symlink out of /tmp turns `/tmp/live/link` — a path under /tmp when this ran —
+# into a write through that symlink. Deletes compose safely and guard-rm-outside-tmp.sh allows
+# several, because `rm` unlinks a symlink rather than following it.
+[ "$proved" -ge 1 ] || exit 0
+[ "$only_ours" = 1 ] && [ "$proved" = 1 ] && decide allow "every path operand is inside a single root"
+exit 0
diff --git a/.claude/hooks/guard-rm-outside-tmp.sh b/.claude/hooks/guard-rm-outside-tmp.sh
index 66d4dd27b5..4d253ffc62 100755
--- a/.claude/hooks/guard-rm-outside-tmp.sh
+++ b/.claude/hooks/guard-rm-outside-tmp.sh
@@ -1,14 +1,17 @@
#!/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).
+# PreToolUse guard for `rm`: auto-allow deletes whose every operand is a whitelisted target —
+# under /tmp, or inside a git working tree located in $HOME (a version-controlled project dir).
+# Any other command that runs `rm` gets an explicit `ask`, which is the ordinary permission
+# prompt and the only one `rm` gets (see lib-guarded-verb.sh); a command that runs no `rm` at
+# all makes no decision (exit 0).
#
-# 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.
+# The command is read one segment at a time, so chaining and line breaks carry no weight of
+# their own: `rm -f /tmp/a && rm -rf /tmp/b` is two deletes, each proved on its own operands.
+# A decision covers the whole command line, so `allow` is emitted only when every segment is
+# an `rm` this guard proved or a `cd` it could resolve. A line that mixes a proven `rm` with
+# some other command makes no decision instead and leaves that line to the normal permission
+# flow: the delete is not what needed a prompt, and waving the rest of the line through with
+# it would turn a trailing `rm -f /tmp/x` into a way to auto-approve anything.
#
# 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
@@ -17,15 +20,16 @@
# 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
+# Which targets those two roots cover, and the tradeoff they rest on, is `path_class` in
+# lib-guarded-verb.sh. 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.
+# against the working directory the command runs from, which a `cd` in an earlier segment
+# moves; once a `cd` is one this guard cannot resolve, that directory is unknown and a
+# relative operand can no longer be proved.
#
# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env.
set -uo pipefail
+. "${BASH_SOURCE[0]%/*}/lib-guarded-verb.sh"
input=$(cat)
command -v jq >/dev/null 2>&1 || exit 0
@@ -33,74 +37,104 @@ 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
+# Every bail-out below goes through `defer`, so the forms this guard refuses to reason about —
+# wrapped, quoted, expanded — still reach the user as a prompt whenever an `rm` runs among them.
+runs_verb rm "$cmd" && guarded=1 || guarded=0
+defer() {
+ [ "$guarded" = 1 ] && decide ask "$1"
+ exit 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
+has_substitution "$cmd" && defer "command substitution in the command line"
+
+# Proves one `rm` segment, whose tokens are in SEG_TOKS with `rm` at index 0, resolving relative
+# operands against $seg_cwd. Returns only once every operand is an auto-allowable target;
+# anything it cannot prove defers instead.
+check_rm_segment() {
+ local i=1 t canon candidates had_operand=0 end_opts=0
+ while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do
+ t="${SEG_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._/*?[]-')" ] && defer "unsafe characters in \`$t\`"
+ # 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 -*[*?[]*) defer "glob inside the option \`$t\`" ;; 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
- 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) ;;
+ 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 *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac
+ # A relative operand has as many candidate paths as the command has candidate working
+ # directories, and every one of them has to be auto-allowable: a `cd` that fails at runtime
+ # leaves the delete running in the directory it started in.
+ case "$t" in
+ /*) candidates=$(realpath -m -- "$t" 2>/dev/null) ;;
+ *) [ -n "$seg_cwd" ] || defer "\`$t\` is relative to a working directory this guard cannot pin down"
+ candidates=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null)
+ [ -n "$alt_cwd" ] && candidates="$candidates
+$(realpath -m -- "$alt_cwd/$t" 2>/dev/null)"
+ ;;
+ esac
+ while IFS= read -r canon; do
+ [ -n "$canon" ] || defer "cannot resolve \`$t\`"
+ # 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/?*) ;; *) defer "glob \`$t\` is outside /tmp" ;; esac ;; esac
+ path_class "$canon" >/dev/null || defer "\`$canon\` is outside /tmp and not inside a git checkout in \$HOME"
+ done <<< "$candidates"
+ done
+ [ "$had_operand" = 1 ] || defer "no operand"
+}
+
+split_segments "$cmd"
+seg_cwd="${cwd:-$PWD}"
+alt_cwd="" # where a `cd` that failed would have left the command
+saw_cd=0
+proved=0 # at least one `rm` segment came out auto-allowable
+only_ours=1 # ... and nothing else shares the command line
+
+for seg in "${SEGMENTS[@]}"; do
+ segment_tokens "$seg"
+ case "${SEG_TOKS[0]:-}" in
+ "") continue ;;
+ rm)
+ check_rm_segment
+ proved=1
+ continue
+ ;;
+ cd)
+ # A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative
+ # operand points, to one of the two candidates `apply_cd` describes.
+ if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then
+ alt_cwd="$seg_cwd"
+ seg_cwd="$new_cwd"
+ else
+ # Not the harmless segment an allow assumes: whatever this guard could not account for
+ # may be a redirect, and a redirect writes. Leave the line to the normal flow.
+ seg_cwd="" alt_cwd=""
+ only_ours=0
+ fi
+ saw_cd=1
+ continue
+ ;;
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
+ # Some other command shares the line. If an `rm` runs inside it after all — behind a wrapper,
+ # an env prefix or a path — this guard cannot say what it deletes.
+ segment_runs_verb rm "$seg" && defer "rm is not the leading command word in \`$seg\`"
+ only_ours=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"}}'
+[ "$proved" = 1 ] || exit 0
+[ "$only_ours" = 1 ] && decide allow 'rm operands are under /tmp or inside a git checkout in $HOME'
+exit 0
diff --git a/.claude/hooks/lib-guarded-verb.sh b/.claude/hooks/lib-guarded-verb.sh
new file mode 100644
index 0000000000..6ef76a5466
--- /dev/null
+++ b/.claude/hooks/lib-guarded-verb.sh
@@ -0,0 +1,268 @@
+#!/usr/bin/env bash
+# Sourced by the PreToolUse guards; not a hook itself.
+#
+# A permission rule beats a hook: an `ask` rule prompts whatever a PreToolUse hook returns, which
+# makes the hook's `allow` dead weight. So settings.json carries no `ask` rule for `rm`, `mv` or
+# `chmod`, and the guards own both halves — `allow` what they can prove safe, `ask` for the rest.
+# Removing a guard's `ask` path therefore removes that verb's prompt entirely.
+#
+# `set -f` is global to the sourcing script so that the unquoted word split in runs_verb cannot
+# expand a glob operand against the filesystem. Neither guard relies on pathname expansion.
+set -f
+
+# 0 iff ($1) starts with a command that only reads its input. An allowlist, because the
+# opposite — naming the shells to avoid — would have to be complete: an unlisted one (`ash`,
+# `rbash`, `busybox sh`) executes the body while the guard calls it data. Unrecognized here only
+# costs a prompt. Text with no command word in it is not evidence of a reader either.
+reads_only() {
+ local w
+ for w in $1; do
+ w="${w//[\"\'\\]/}"
+ w="${w%%<<*}" # a redirect needs no space: `cat<'* | '<'*) continue ;; esac
+ case "${w##*/}" in
+ cat | tee | head | tail | grep | sed | awk | sort | uniq | wc | cut | diff | tr \
+ | jq | yq | gh | git | base64 | column | envsubst | python | python3 | node \
+ | psql | mysql | sqlite3 | wmill) return 0 ;;
+ esac
+ return 1
+ done
+ return 1
+}
+
+# A heredoc body is data rather than commands only when its delimiter is quoted and nothing
+# executes it; a rule doesn't match a verb inside such a body, and a PR body would otherwise
+# prompt for every `rm` in its text. Dropping one needs all of that, a delimiter that could
+# really open a heredoc, and a terminator line — failing any part, nothing is dropped.
+strip_heredoc_bodies() {
+ local -a lines=()
+ local line delim rest after trimmed piped quoted i j n
+ while IFS= read -r line; do lines+=("$line"); done <<< "$1"
+ n=${#lines[@]}
+ i=0
+ while [ "$i" -lt "$n" ]; do
+ line="${lines[$i]}"
+ printf '%s\n' "$line"
+ i=$((i + 1))
+ # A `#` opens a comment, and a comment opens no heredoc — including mid-line, as in
+ # `echo hi # cat < f`); prose after it means the `<<` sits
+ # inside a string (`echo "cat < f"` ends its redirect-looking text with the
+ # closing quote. That also refuses `cat < "f"`, a real heredoc, which only over-prompts.
+ after="${rest#"$delim"}"
+ after="${after#"${after%%[![:space:]]*}"}"
+ case "$after" in
+ *[\"\'\\]*) continue ;;
+ "" | '>'* | '<'* | '|'* | [0-9]'>'* | [0-9]'<'*) ;;
+ *) continue ;;
+ esac
+ # A real delimiter is a bare word or one wholly quoted (`<<'EOF'`, `<<\EOF`); a stray quote
+ # left in it means the `<<` was quoted prose.
+ quoted=0
+ case "$delim" in
+ \'*\' | \"*\") delim="${delim:1:${#delim}-2}" quoted=1 ;;
+ \\?*) delim="${delim#\\}" quoted=1 ;;
+ esac
+ case "$delim" in
+ [A-Za-z_]*) ;;
+ *) continue ;;
+ esac
+ case "$delim" in *[!A-Za-z0-9_]*) continue ;; esac
+ # Only a quoted delimiter makes the body inert. Unquoted, the shell expands it before the
+ # consumer ever sees it, so a `$(rm -rf ~)` written in the body runs whatever reads it.
+ [ "$quoted" = 1 ] || continue
+ # Two commands can see this body: the one the `<<` belongs to, and anything it is then piped
+ # into. The first is whatever was started last before the `<<`, so splitting the text there
+ # on separators and substitution openers and taking the final piece finds `cat` in
+ # `--title "fix(agents): …" --body "$(cat <<`, without the title's parenthesis standing in
+ # for it. A line continuation (`bash \` then `<<'EOF'`) leaves that piece empty, which is
+ # not evidence of a reader and so keeps the body.
+ reads_only "$(printf '%s' "${line%%<<*}" | tr ';&|()`' '\n' | grep -v '^[[:space:]]*$' | tail -1)" || continue
+ piped="$after"
+ while :; do
+ case "$piped" in *'|'*) ;; *) break ;; esac
+ piped="${piped#*|}"
+ reads_only "${piped%%|*}" || continue 2
+ done
+ j="$i"
+ while [ "$j" -lt "$n" ]; do
+ trimmed="${lines[$j]#"${lines[$j]%%[![:space:]]*}"}"
+ [ "$trimmed" = "$delim" ] && break
+ j=$((j + 1))
+ done
+ [ "$j" -lt "$n" ] && i=$((j + 1))
+ done
+}
+
+# 0 iff ($1) runs as a command word in ($2), which must already be one
+# segment (no separator left in it). Wrapper, env-prefix and `/bin/` forms all count.
+segment_runs_verb() {
+ local verb="$1" w wrapped=0
+ for w in $2; do
+ # The shell strips quotes and backslashes before it looks up the command, so `'rm'` and
+ # `r\m` run rm and have to compare equal to it.
+ w="${w//[\"\'\\]/}"
+ case "$w" in
+ "$verb" | */"$verb") return 0 ;;
+ *=*) ;; # leading env assignment
+ -* | *'>'* | *'<'*) ;; # a flag, or a leading redirect
+ [0-9]*) [ "$wrapped" = 1 ] || break ;; # a wrapper's duration, not `1:` in prose
+ '!' | '{' | '}' | if | then | elif | else | while | until | do) ;; # never the command
+ timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env)
+ wrapped=1 ;;
+ # A wrapper's option value is indistinguishable from a command name (`stdbuf -o L rm`),
+ # so past a wrapper the scan runs to the end of the segment instead of stopping at the
+ # first ordinary word. Before one, that word is the command and the verb cannot follow
+ # it. Nothing bounds the scan: a wrapper takes unboundedly many operands
+ # (`env -u A -u B ...`), and any cutoff — a word count, or stopping at the first quoted
+ # word — drops the prompt for a real `sudo -u 'root' rm`. Prose after a wrapper is the
+ # price, and it only over-prompts.
+ *) [ "$wrapped" = 1 ] || break ;;
+ esac
+ done
+ return 1
+}
+
+# Splits ($1) into its command segments, into the global array SEGMENTS. Every guard
+# reasons one segment at a time, so `a && b` is two commands here rather than one unparsable
+# blob, and a newline is a separator like any other.
+#
+# The split set carries more than `; & |` and newlines: `$(`, backticks and `( )` open a nested
+# command, and a separator that only ended statements would read `echo $(rm -rf ~)` as an
+# `echo`. Braces are handled as words rather than separators, since splitting on them cuts
+# `xargs -I {} … rm` in half and strands the `rm` in a segment that no longer knows a wrapper
+# preceded it.
+#
+# `tr` and not `${1//[...]}`: a `}` inside the bracket expression closes the expansion itself,
+# which silently leaves the command unsplit and every separator unseen.
+split_segments() {
+ local seg
+ SEGMENTS=()
+ while IFS= read -r seg; do SEGMENTS+=("$seg"); done <<< "$(strip_heredoc_bodies "$1" | tr ';&|()`' '\n')"
+}
+
+# 0 iff ($1) carries a command substitution outside a heredoc body. A substitution is
+# concatenated into the word it sits in, and splitting on its opener cuts that word in half:
+# `/tmp/a/`printf ../../etc`` would be proved as `/tmp/a/`, with the traversal validated as an
+# unrelated segment. Nothing here can evaluate it, so a guard proves nothing about such a
+# command. Heredoc bodies are excepted — those are data the split has already dropped.
+has_substitution() {
+ case "$(strip_heredoc_bodies "$1")" in
+ *'$('* | *'`'*) return 0 ;;
+ esac
+ return 1
+}
+
+# Reads ($1) into the global array SEG_TOKS, dropping the shell keywords that can
+# precede a command word so that `then rm -rf x` is analyzed as the `rm` it runs. Word
+# splitting only: quotes are left in the token and fail the guards' charset check downstream,
+# which is what keeps `rm -rf "$HOME/x"` unprovable.
+segment_tokens() {
+ SEG_TOKS=()
+ read -r -a SEG_TOKS <<< "$1"
+ while [ "${#SEG_TOKS[@]}" -gt 0 ]; do
+ case "${SEG_TOKS[0]}" in
+ '!' | '{' | '}' | if | then | elif | else | while | until | do) SEG_TOKS=("${SEG_TOKS[@]:1}") ;;
+ *) break ;;
+ esac
+ done
+}
+
+# Prints the directory a `cd` lands in, given the current one ($1) and the tokens after the
+# `cd` ($2...). Fails, printing nothing, when the destination cannot be resolved — a variable,
+# `-`, an option, a relative path, no operand at all (`cd` alone is $HOME), or more than one.
+#
+# Resolving says nothing about whether the `cd` will SUCCEED: the destination may not exist, and
+# `;` runs the next command anyway, leaving it in the directory it started in. So a caller may
+# never treat this as the working directory outright — it is one of two candidates, and a
+# relative operand has to be provable against the one the command started in as well. That also
+# makes a `cd` word splitting invented out of quoted text harmless: it can only add a candidate,
+# never drop one. Past the first `cd` the branching outruns two candidates, so a caller that
+# sees a second gives up on relative operands entirely.
+apply_cd() {
+ local cwd="$1" t
+ shift
+ [ "$#" -eq 1 ] || return 1
+ t="$1"
+ [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1
+ # Absolute only. A relative destination is not `$cwd/$t`: the shell searches $CDPATH first,
+ # so `cd ssh` may land in /etc/ssh, and this cannot see the caller's $CDPATH to rule it out.
+ case "$t" in /*) ;; *) return 1 ;; esac
+ realpath -m -- "$t" 2>/dev/null
+}
+
+# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, or
+# `repo:` for one strictly inside the git working tree at , itself under $HOME.
+# Fails, printing nothing, for anything else — those are the only roots the guards are willing
+# to touch unprompted. The root is part of the class so that a caller pairing two operands can
+# tell one checkout from another: sibling repos are separate permission boundaries, not one.
+#
+# The `repo` class trades on "this is a project under version control" being lower-stakes than
+# the same act 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.
+#
+# The walk stops at $HOME, so a dotfiles repo at ~ can't put all of $HOME in a class, and
+# top-level ~ files stay out of one. A working tree's own root folder counts only when it is a
+# linked worktree, whose `.git` is a pointer file so the history lives in the main repo and
+# survives; a primary checkout's `.git` is a directory holding the history itself, so losing it
+# is unrecoverable.
+#
+# Some paths are in no class in any root, /tmp included. Git history, and the agent's own guards
+# and settings, because removing those is what removes the prompt on everything else. And every
+# path `.claude/settings.json` refuses to read — `.env`, `secrets/`, `*.pem`, `*.key`,
+# `credentials.json`, `.secret*` — because a `cp` or `mv` that is auto-allowed on both ends
+# would rename one out of those globs and hand back through `Read` exactly what they deny.
+path_class() {
+ local canon="$1" d root=""
+ case "$canon" in
+ *"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;;
+ *"/.env" | *"/.env."*) return 1 ;;
+ *"/secrets" | *"/secrets/"*) return 1 ;;
+ *.pem | *.key | *"/credentials.json") return 1 ;;
+ *"/.secret"* | *.secret | *.secrets) return 1 ;;
+ esac
+ case "$canon" in /tmp/?*) printf 'tmp'; return 0 ;; esac
+ [ -n "${HOME:-}" ] || return 1
+ case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac
+ 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
+ [ -f "$root/.git" ] || return 1
+ fi
+ printf 'repo:%s' "$root"
+}
+
+# 0 iff ($1) runs as a command word anywhere in ($2). Mirrors how a Bash
+# permission rule matches, so that owning the prompt here doesn't narrow what used to prompt:
+# a guard consults this before it starts proving segments, and every bail-out it then takes
+# is a prompt for exactly the commands a rule would have caught.
+runs_verb() {
+ local verb="$1" seg
+ split_segments "$2"
+ for seg in "${SEGMENTS[@]}"; do
+ segment_runs_verb "$verb" "$seg" && return 0
+ done
+ return 1
+}
+
+# Emit a PreToolUse decision and exit. `ask` is the ordinary permission prompt.
+decide() {
+ jq -nc --arg d "$1" --arg r "$2" \
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}'
+ exit 0
+}
diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh
new file mode 100644
index 0000000000..01ed237baf
--- /dev/null
+++ b/.claude/hooks/test-hooks.sh
@@ -0,0 +1,219 @@
+#!/usr/bin/env bash
+# Decision table for the two scratch-dir PreToolUse guards. Run: bash .claude/hooks/test-hooks.sh
+#
+# What this pins is the `ask` column: a matcher change that turns one into a no-decision drops
+# that command's only prompt (see lib-guarded-verb.sh). The wrapper, nested-command and quoted
+# rows are the ones that catch it.
+#
+# The `allow` column carries its own weight, because a decision covers the whole command line:
+# `allow` may only appear where every segment was proved here, and a line that also runs
+# something unexamined has to come out `none` so the normal permission flow still sees it.
+set -uo pipefail
+H="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
+CWD="$(git -C "$H" rev-parse --show-toplevel)"
+OUT="$HOME/not-a-git-tree" # never written to; only the guards' path checks look at it
+fails=0
+
+# A tree's own root is auto-allowable only when it is a LINKED worktree, whose `.git` is a
+# pointer file so the history lives in the main repo and survives; a primary checkout's `.git`
+# is the history itself. The suite runs from either kind, so the rows that name the root follow
+# the one it is run in — which is also what pins both halves of that rule.
+if [ -f "$CWD/.git" ]; then
+ ROOT_SOLO=allow ROOT_CHAINED=none # linked worktree
+else
+ ROOT_SOLO=ask ROOT_CHAINED=ask # primary checkout
+fi
+
+run() { # run
+ local hook="$1" want="$2" cmd="$3" out got
+ out=$(jq -nc --arg c "$cmd" --arg w "$CWD" \
+ '{tool_name:"Bash",tool_input:{command:$c},cwd:$w}' | "$H/$hook" 2>&1)
+ if [ -z "$out" ]; then
+ got=none
+ else
+ got=$(printf '%s' "$out" | jq -r '.hookSpecificOutput.permissionDecision // "PARSE-ERROR"' 2>/dev/null || echo PARSE-ERROR)
+ fi
+ local shown="${cmd//$'\n'/ ⏎ }"
+ if [ "$got" = "$want" ]; then
+ printf ' ok %-5s %s\n' "$got" "$shown"
+ else
+ printf 'FAIL want=%-5s got=%-5s %s\n %s\n' "$want" "$got" "$shown" "$out"
+ fails=$((fails + 1))
+ fi
+}
+
+echo "== guard-rm-outside-tmp.sh =="
+G=guard-rm-outside-tmp.sh
+run $G allow "rm -rf /tmp/scratch/x"
+run $G allow "rm -rf /tmp/scratch/*"
+run $G allow "rm -rf $CWD/frontend/scratch"
+run $G ask "rm -rf /tmp"
+run $G ask "rm -rf $OUT"
+run $G ask "rm -rf $CWD/.git"
+run $G ask "rm -rf $CWD/.claude/hooks" # the guards may not delete themselves
+run $G ask "rm $CWD/.claude/settings.json"
+run $G ask "rm $CWD/.claude/settings.local.json"
+run $G ask "rm -rf $CWD/backend/.env"
+run $G ask "rm -rf $CWD/.env.local"
+run $G $ROOT_SOLO "rm -rf $CWD"
+run $G ask "rm -rf $CWD/*"
+run $G ask "rm -rf /etc/passwd"
+run $G ask 'rm -rf "$HOME/x"'
+run $G ask "rm -rf /tmp/../$OUT"
+run $G none "ls /tmp && rm -rf /tmp/x" # proved delete, unexamined neighbour
+run $G ask 'echo $(rm -rf /etc)'
+run $G ask 'echo `rm -rf /etc`'
+run $G ask "{ rm -rf /etc; }"
+run $G allow "{ rm -rf /tmp/scratch/x; }" # the keyword drops, the delete still proves
+run $G ask "find . -name x | xargs rm"
+run $G ask "timeout 5 rm -rf /tmp/x"
+run $G ask "stdbuf -o L rm -rf /etc"
+run $G ask "FOO=bar rm -rf /tmp/x"
+run $G ask "/bin/rm -rf /tmp/x"
+run $G ask "'rm' -rf /etc"
+run $G ask 'r\m -rf /etc'
+run $G ask "! rm -rf /etc"
+run $G ask "if true; then rm -rf /etc; fi"
+run $G ask ">/dev/null rm -rf $OUT"
+# Data that merely mentions a verb is not a command. Both of these prompted in the field.
+run $G none "$(printf 'gh pr create --body "$(cat <<%sEOF%s\ndrop `rm` and `mv` from the ask list\nrm is now guarded here\nEOF\n)"' "'" "'")"
+run $G none "$(printf 'claude -p "run these in order:\n1: rm -rf /tmp/a\n2: mv /tmp/b /tmp/c"')"
+# A wrapper's own flags and assignments are unbounded, so they may not be charged against the
+# scan that looks past it — these run rm and must prompt.
+run $G ask "env -i HOME=/tmp PATH=/usr/bin LANG=C USER=root SHELL=/bin/sh rm -rf /etc"
+run $G ask "sudo -E -H -u root FOO=1 BAR=2 rm -rf $OUT"
+run $G ask "xargs -a f -d d -E e -I {} -L 1 -n 1 rm /etc"
+run $G ask "env -u A -u B -u C -u D -u E -u F -u G rm -rf /etc"
+run $G ask "sudo -u 'root' rm -rf /etc"
+run $G ask "$(printf 'echo hi # cat < f"\nrm -rf /etc\nEOF')"
+run $G ask "$(printf 'echo "cat < /tmp/a"\nrm -rf /etc\ntrue')"
+run $G ask "$(printf "echo 'cat < /tmp/a\nrm -rf /etc\nEOF' "'" "'")"
+run $G none "$(printf 'cat <<%sEOF%s 2>&1 | tee /tmp/a\nrm -rf /etc\nEOF' "'" "'")"
+# An unquoted body is expanded before its consumer sees it, so it is code.
+run $G ask "$(printf 'cat < /tmp/a\n$(rm -rf /etc)\nEOF')"
+run $G ask "$(printf 'cat < /tmp/a\nrm -rf /etc\nEOF')"
+# ... but a real command after a heredoc still is one.
+run $G ask "$(printf 'cat < /tmp/s.sh\nhello\nEOF\nrm -rf %s' "$OUT")"
+run $G ask "$(printf 'echo "a << b"\nrm -rf %s' "$OUT")"
+run $G none "git rm frontend/foo.ts"
+run $G none 'echo $(ls /tmp)'
+run $G none 'grep -rn "rm" backend/'
+run $G none "cargo build --release"
+
+# Chaining and line breaks are not themselves a reason to prompt: each segment is proved on its
+# own operands, and a `cd` moves where a relative one points.
+run $G allow "rm -f /tmp/a; rm -rf /tmp/b"
+run $G allow "$(printf 'rm -f /tmp/a\nrm -rf %s/frontend/scratch' "$CWD")"
+run $G allow "cd /tmp/scratch && rm -rf sub"
+run $G none "mkdir -p /tmp/x && rm -rf /tmp/x"
+run $G ask "$(printf 'ls /tmp\nrm -rf /etc')"
+# A `cd` this guard can resolve is where the relative operand lands; one it cannot leaves the
+# working directory unknown, and an unknown one proves nothing.
+run $G ask "cd /etc && rm -rf foo"
+run $G ask 'cd "$D" && rm -rf foo'
+run $G ask "cd $CWD && rm -rf .git"
+run $G ask "cd /etc && cd /tmp/scratch && rm -rf sub" # a cd out is not walked back
+# A `cd` can fail at runtime, and `;` runs the delete from where the command started, so a
+# relative operand is proved from both directories.
+run $G ask "cd /tmp/does-not-exist; rm -rf .git"
+run $G ask "cd /tmp/does-not-exist; rm -rf backend/.env"
+run $G ask "cd /tmp/a && cd /tmp/b && rm -rf sub"
+run $G ask "rm -rf /tmp/clone/.git" # history is never in a class
+run $G ask "rm -rf /tmp/scratch/id_rsa.key"
+run $G none "cd /tmp >$OUT; rm -f /tmp/a"
+# A substitution is concatenated into its word, so splitting on it would prove only the literal
+# half; a relative `cd` is not $cwd/$t either, since the shell searches $CDPATH first.
+run $G ask 'rm -rf /tmp/a/`printf ../../etc`'
+run $G ask 'rm -rf /tmp/a/$(printf ../../etc)'
+run $G ask "cd ssh && rm -rf moduli"
+
+echo
+echo "== allow-fileops-in-tmp.sh =="
+A=allow-fileops-in-tmp.sh
+run $A allow "mv /tmp/a /tmp/b"
+run $A allow "chmod 755 /tmp/a"
+run $A allow "cp -r /tmp/a /tmp/b"
+run $A allow "tar -xzf /tmp/a.tar.gz -C /tmp/out"
+run $A ask "mv /tmp/a $OUT"
+run $A ask "mv $CWD/AGENTS.md /tmp/a"
+run $A $ROOT_SOLO "chmod -R 777 $CWD"
+run $A none "ls && mv /tmp/a /tmp/b" # proved move, unexamined neighbour
+run $A ask 'echo $(mv /tmp/a /etc)'
+run $A ask "timeout --signal KILL 5 mv /tmp/a /etc"
+run $A ask "time -f FORMAT chmod 777 $OUT"
+run $A ask "'mv' /tmp/a /etc"
+run $A ask 'ch\mod 777 /etc'
+run $A none "$(printf 'claude -p "run these in order:\n1: rm -rf /tmp/a\n2: mv /tmp/b /tmp/c"')"
+run $A ask "env -i A=1 B=2 C=3 D=4 E=5 F=6 mv /tmp/a /etc"
+run $A none "cp $CWD/AGENTS.md /tmp/a"
+run $A none "tar -xzf /tmp/a.tar.gz -C $OUT"
+run $A none "cargo build"
+
+run $A none "mkdir -p /tmp/x; mv /tmp/a /tmp/x; chmod 755 /tmp/x" # one write per line
+run $A none "$(printf 'mv /tmp/a /tmp/b\nchmod 755 /tmp/b')"
+run $A ask "ls && mv /tmp/a /etc"
+run $A $ROOT_CHAINED "$(printf 'mkdir -p /tmp/x\nchmod -R 777 %s' "$CWD")"
+run $A allow "cd /tmp/x && tar -xzf /tmp/a.tar.gz -C /tmp/out"
+# The checkout is a root of its own, so an in-repo move or chmod is as auto-allowable as the
+# in-repo delete already was — but one operation may not straddle it and /tmp.
+run $A allow "chmod +x scripts/worktree-env"
+run $A allow "mv backend/.sqlx backend/.sqlx.bad"
+run $A allow "mv $CWD/frontend/a.ts $CWD/frontend/b.ts"
+run $A ask "mv /tmp/a $CWD/frontend/a.ts"
+run $A ask "chmod -R 777 $CWD/.git"
+run $A ask "mv $CWD/backend/.env $CWD/backend/.env.bak"
+run $A ask "mv $CWD/AGENTS.md $OUT"
+run $A ask "cd /etc && mv a b"
+# An auto-allowed rename may not carry a path out of the `Read` deny globs.
+run $A ask "mv backend/server.pem backend/server.txt"
+run $A none "cp backend/secrets/token frontend/token.txt" # cp has no prompt of its own,
+ # so what matters is it is not allowed
+run $A ask "mv $CWD/backend/credentials.json /tmp/x"
+run $A ask "cd /tmp/does-not-exist; mv .claude/settings.json settings.bak"
+# A segment this hook cannot read whole may carry a redirect, and an earlier write can change
+# what a later operand resolves to — neither may ride along on an allow.
+run $A none "cd /tmp >$OUT; mv /tmp/a /tmp/b"
+run $A none "cp -r /tmp/tree /tmp/live; cp /tmp/payload /tmp/live/link"
+run $A ask 'mv /tmp/a/`printf ../../etc/x` /tmp/b'
+# A sibling checkout is a different root: its files are outside what the Read tool is confined
+# to, and copying them in would hand back what that confinement withholds.
+EE="$(dirname "$CWD")/windmill-ee-private" # a sibling checkout; absent elsewhere, still not a root
+run $A ask "mv $EE/backend/x.rs $CWD/backend/x.rs"
+run $A none "cp $EE/README.md $CWD/README.copy"
+# Directory form writes a path the command does not name — DEST/basename(SRC) — and `cp`
+# follows that child when it is a symlink, as every `*_ee.rs` in this checkout is.
+run $A ask "mv frontend/apps_ee.rs backend/windmill-api/src"
+run $A none "cp frontend/apps_ee.rs backend/windmill-api/src"
+run $A none "cp frontend/a.ts backend"
+run $A ask "mv /tmp/a $CWD/backend"
+# ... and a `cd` that fails at runtime may not hide that form: the destination is a directory
+# in the directory the command actually ran in, whichever of the two that turns out to be.
+run $A none "cd $CWD/AGENTS.md; cp frontend/apps_ee.rs backend/windmill-api/src"
+run $A ask "cd $CWD/AGENTS.md; mv frontend/apps_ee.rs backend/windmill-api/src"
+run $A none "cd /tmp/x && tar -xzf /tmp/a.tar.gz" # no -C, and the cwd is now two candidates
+run $A allow "cp frontend/a.ts backend/a.ts" # ... naming the destination proves fine
+
+echo
+[ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILURES"; exit 1; }
diff --git a/.claude/settings.json b/.claude/settings.json
index 641ea70b53..a464ca3719 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -73,10 +73,7 @@
"Edit(**/.env.*)"
],
"ask": [
- "Bash(rm:*)",
"Bash(rmdir:*)",
- "Bash(mv:*)",
- "Bash(chmod:*)",
"Bash(chown:*)",
"Bash(truncate:*)",
"Bash(shred:*)",
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000000..0e022ec801
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,14 @@
+
+
+## What does this PR do?
+
+## Related issue
diff --git a/.github/workflows/ai-evals-test.yml b/.github/workflows/ai-evals-test.yml
index 3ee7aed876..71e79395f3 100644
--- a/.github/workflows/ai-evals-test.yml
+++ b/.github/workflows/ai-evals-test.yml
@@ -22,6 +22,7 @@ on:
- "frontend/src/lib/userDraft.svelte.ts"
- "frontend/src/lib/userDraftDbSyncer.svelte.ts"
- "frontend/src/lib/infer.ts"
+ - "frontend/src/lib/components/sessions/**"
- ".github/workflows/ai-evals-test.yml"
pull_request:
types: [opened, reopened, ready_for_review]
@@ -35,6 +36,7 @@ on:
- "frontend/src/lib/userDraft.svelte.ts"
- "frontend/src/lib/userDraftDbSyncer.svelte.ts"
- "frontend/src/lib/infer.ts"
+ - "frontend/src/lib/components/sessions/**"
- ".github/workflows/ai-evals-test.yml"
concurrency:
@@ -124,6 +126,11 @@ jobs:
bun install
bun test adapters/
+ # Harness code that reaches into the frontend module graph; bun cannot load it.
+ - name: Run harness unit tests (frontend graph)
+ working-directory: ./ai_evals
+ run: bun run test:frontend-graph
+
- name: Run global AI evals
timeout-minutes: 20
working-directory: ./ai_evals
diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml
index 061165530d..e26231eda4 100644
--- a/.github/workflows/cli-tests.yml
+++ b/.github/workflows/cli-tests.yml
@@ -9,6 +9,13 @@ on:
- "windmill-yaml-validator/**"
- "backend/migrations/**"
- ".github/workflows/cli-tests.yml"
+ # The bundles cli/ vendors from the frontend: their drift guards live in
+ # cli/test but the edits that break them land here. The policy bundle
+ # inlines its imports too, so those sources belong in the filter.
+ - "frontend/src/lib/components/raw_apps/**"
+ - "frontend/src/lib/components/recording/**"
+ - "frontend/src/lib/components/apps/editor/commonAppUtils.ts"
+ - "frontend/src/lib/components/apps/inputType.ts"
pull_request:
branches: [main]
paths:
@@ -16,6 +23,13 @@ on:
- "windmill-yaml-validator/**"
- "backend/migrations/**"
- ".github/workflows/cli-tests.yml"
+ # The bundles cli/ vendors from the frontend: their drift guards live in
+ # cli/test but the edits that break them land here. The policy bundle
+ # inlines its imports too, so those sources belong in the filter.
+ - "frontend/src/lib/components/raw_apps/**"
+ - "frontend/src/lib/components/recording/**"
+ - "frontend/src/lib/components/apps/editor/commonAppUtils.ts"
+ - "frontend/src/lib/components/apps/inputType.ts"
env:
CARGO_TERM_COLOR: always
diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml
index d2acde0dbe..4bfb5b4147 100644
--- a/.github/workflows/pr-ready-review.yml
+++ b/.github/workflows/pr-ready-review.yml
@@ -167,8 +167,14 @@ jobs:
env:
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
run: |
+ # prior-comments.md is PR comment text verbatim, and commenting needs no write access.
+ # With a fixed delimiter, a comment containing a bare `EOF` line closes the block early:
+ # the step dies, and whatever follows in that comment is read as further environment
+ # assignments for the rest of this job, which holds the review tokens. Hence a random
+ # delimiter, per GitHub's guidance for untrusted multiline values.
+ delimiter="REVIEW_PROMPT_EOF_$(openssl rand -hex 16)"
{
- echo 'REVIEW_PROMPT<> "$GITHUB_ENV"
- name: Automatic PR Review
diff --git a/.github/workflows/pr-review-commands.yml b/.github/workflows/pr-review-commands.yml
index 3c04e4e5f2..d29ffe6561 100644
--- a/.github/workflows/pr-review-commands.yml
+++ b/.github/workflows/pr-review-commands.yml
@@ -25,16 +25,20 @@ jobs:
REMAINDER_FIRST_LINE=${FIRST_LINE#"$FIRST_WORD"}
REMAINDER_FIRST_LINE=${REMAINDER_FIRST_LINE# }
REST=$(printf '%s' "$BODY" | tail -n +2)
+ # The value is the comment body, which anyone can write. A fixed delimiter lets a
+ # comment close the block early and have the rest of itself read as further step
+ # outputs, so the delimiter has to be unguessable.
+ delimiter="EXTRA_EOF_$(openssl rand -hex 16)"
{
echo "command=$COMMAND"
- echo 'extra_prompt<> "$GITHUB_OUTPUT"
;;
*)
diff --git a/.github/workflows/sign-cla.yml b/.github/workflows/sign-cla.yml
index 67822542a9..52329b6119 100644
--- a/.github/workflows/sign-cla.yml
+++ b/.github/workflows/sign-cla.yml
@@ -21,9 +21,15 @@ jobs:
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
with:
path-to-signatures: "signatures/cla.json"
- path-to-document: "https://github.com/windmill-labs/windmill/blob/master/CLA.md"
+ path-to-document: "https://github.com/windmill-labs/windmill/blob/main/CLA.md"
branch: "signatures"
allowlist: rubenfiszel,bot*
+ custom-notsigned-prcomment: |
+ Thank you for taking the time to open this PR.
+
+ Please note that **we are not seeking outside contribution at this time**. Small, trivially-verified PRs that fix a problem are still accepted, but low-value PRs (e.g. typo fixes) and PRs longer than a dozen or so lines will be closed. If you have a bigger idea, please open a [feature request](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md) instead. See [CONTRIBUTING.md](https://github.com/windmill-labs/windmill/blob/main/CONTRIBUTING.md) for the full policy.
+
+ If your PR falls within that scope, we ask that you sign our [Contributor License Agreement](https://github.com/windmill-labs/windmill/blob/main/CLA.md) before we can accept it. You can sign the CLA by just posting a Pull Request Comment same as the below format.
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
#remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository)
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 5b2dedb3d9..d66e79c5fa 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "1.789.0"
+ ".": "1.792.2"
}
diff --git a/AGENTS.md b/AGENTS.md
index aab920746b..1da4be6c98 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -42,6 +42,7 @@ Open-source platform for internal tools, workflows, API integrations, background
- **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).
+- **Raw-app policy**: `frontend/src/lib/components/raw_apps/rawAppPolicy.ts` also derives the policy the server's raw-app deploy stores, vendored into the bundle job as `backend/windmill-api/src/apps_raw_policy.gen.js`. After changing it or anything it imports, run `bun run gen:app-policy` from `cli/` (`cli/test/app_policy_bundle_unit.test.ts` fails otherwise). It rides in the job rather than being read from the CLI the job runs because the images install `windmill-cli` unpinned, so an image can carry one older than its server.
## Dev Environment
@@ -146,10 +147,19 @@ $NAV --root backend callees "X" # what does X call?
- **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics
- **Scratch stays outside the checkout.** Temp scripts, data dumps, cache backups and
screenshots go in the session scratch directory or `/tmp`, so nothing temporary can end up
- committed. Write `rm`/`mv`/`cp` as one plain unchained command: a PreToolUse hook
- auto-allows those when every operand is under `/tmp` or inside this checkout, but it defers
- on `&&`, `;`, redirects, quotes and `$VAR` — that deferral, not the delete itself, is what
- turns a routine cleanup into a permission prompt.
+ committed. Write the paths in `rm`/`mv`/`cp` out literally: a PreToolUse hook proves each
+ operand, and auto-allows deletes, moves, copies and mode changes under `/tmp` or inside a git
+ checkout under `$HOME`, as long as one operation stays within a single root — a sibling
+ checkout is a root of its own (`tar` and `unzip` stay `/tmp`-only). Chain deletes freely, each
+ proved on its own operands, but keep writes to one per line, name the destination rather than
+ a directory to drop it in, and put anything else on its own line: a command the hook does not
+ prove drops the whole line back to the normal permission flow. A
+ quoted or `$VAR` operand, a `~`, a redirect, a `$(…)`, a relative `cd`, or a wrapper like
+ `xargs rm` cannot be proved, and that deferral is what turns a cleanup into a prompt.
+- **Change files with Edit/Write, not the shell.** `sed -i`, `cat > file <<'EOF'` and inline
+ `python3 - <<'PY'` scripts put an edit through the PreToolUse guards and the permission
+ classifier, which match `Bash` and nothing else, so a routine edit arrives as a prompt. Bash
+ stays right for running things — tests, builds, git, one-off queries.
- 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9107cde620..0a729e40a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,101 @@
# Changelog
+## [1.792.2](https://github.com/windmill-labs/windmill/compare/v1.792.1...v1.792.2) (2026-08-19)
+
+
+### Bug Fixes
+
+* check direct-deployment lock and superadmin in the deploy preflight ([#10748](https://github.com/windmill-labs/windmill/issues/10748)) ([ef8a8e8](https://github.com/windmill-labs/windmill/commit/ef8a8e821ca3a5f4a308c49226292565680bf90c))
+* make the listScripts parent_hash filter valid SQL ([#10752](https://github.com/windmill-labs/windmill/issues/10752)) ([f34b7fb](https://github.com/windmill-labs/windmill/commit/f34b7fbcfa104bdc00abbfc59ab4a3dd8cafe0e0))
+* **security:** validate ansible git repository URLs before invoking git ([#10759](https://github.com/windmill-labs/windmill/issues/10759)) ([fa7fbd3](https://github.com/windmill-labs/windmill/commit/fa7fbd348d4b184ea95cfc953ded7c72b6f04e5e))
+
+## [1.792.1](https://github.com/windmill-labs/windmill/compare/v1.792.0...v1.792.1) (2026-08-18)
+
+
+### Bug Fixes
+
+* route legacy AI entry points to sessions instead of the unmounted chat ([#10705](https://github.com/windmill-labs/windmill/issues/10705)) ([494e6f1](https://github.com/windmill-labs/windmill/commit/494e6f146e6a22bd498db58fa10c7866cd56dc4d))
+
+## [1.792.0](https://github.com/windmill-labs/windmill/compare/v1.791.0...v1.792.0) (2026-08-18)
+
+
+### Features
+
+* **frontend:** record the outcome of every AI chat tool call ([#10746](https://github.com/windmill-labs/windmill/issues/10746)) ([7b17e35](https://github.com/windmill-labs/windmill/commit/7b17e358b35bf4ef8c213ea13252a456e87acb32))
+
+
+### Bug Fixes
+
+* **api:** document cache_ignore_s3_path on the Script read schema ([#10742](https://github.com/windmill-labs/windmill/issues/10742)) ([6783a39](https://github.com/windmill-labs/windmill/commit/6783a396b144948fa60324eae888bc4a83917bc8))
+* audit the icon library against brand guidelines ([#10722](https://github.com/windmill-labs/windmill/issues/10722)) ([6749015](https://github.com/windmill-labs/windmill/commit/6749015fbf7afe0c6dcd53b1933b0152915afd32))
+* **cli:** keep script settings on push and repair the up-to-date check ([#10741](https://github.com/windmill-labs/windmill/issues/10741)) ([ef4dc46](https://github.com/windmill-labs/windmill/commit/ef4dc46d4bfd00e583a39e5c053c0022f1ad3abd))
+* show runtime-detected assets in a run's Assets tab ([#10738](https://github.com/windmill-labs/windmill/issues/10738)) ([1fa3bf3](https://github.com/windmill-labs/windmill/commit/1fa3bf3b291c32ffd62f77df59e24993afb7c78a))
+
+## [1.791.0](https://github.com/windmill-labs/windmill/compare/v1.790.1...v1.791.0) (2026-08-17)
+
+
+### Features
+
+* add empty state cards to list pages ([#10726](https://github.com/windmill-labs/windmill/issues/10726)) ([66bffaa](https://github.com/windmill-labs/windmill/commit/66bffaa60d48f992e56e459efb813b24e3942610))
+* **copilot:** let plan mode draw, but never write the plan ([#10725](https://github.com/windmill-labs/windmill/issues/10725)) ([fd9295a](https://github.com/windmill-labs/windmill/commit/fd9295a58e6868ccc6f371e35b875ae27196e99a))
+
+
+### Bug Fixes
+
+* compile resource types with no properties instead of throwing ([#10730](https://github.com/windmill-labs/windmill/issues/10730)) ([b17fdab](https://github.com/windmill-labs/windmill/commit/b17fdab8ff96aa7bbfc8b14389294bda1a9e0a07))
+* derive a raw app's policy on deploy, and default an omitted execution_mode ([#10733](https://github.com/windmill-labs/windmill/issues/10733)) ([343ce6e](https://github.com/windmill-labs/windmill/commit/343ce6e143343e65613d52d0f12c5264b4ab4c3a))
+* include delete_after_secs in script deploy payload ([#10731](https://github.com/windmill-labs/windmill/issues/10731)) ([05eba6c](https://github.com/windmill-labs/windmill/commit/05eba6c9ab078cdedc87f197549dbdbc4b360fe3))
+* support [@typechecked](https://github.com/typechecked) decorator in Python relative imports ([#8495](https://github.com/windmill-labs/windmill/issues/8495)) ([ab3c020](https://github.com/windmill-labs/windmill/commit/ab3c0206d7e9b32676d99ed0cd8c9d8939122584))
+* type s3-streamed columns that are all-null in the inference sample ([#10728](https://github.com/windmill-labs/windmill/issues/10728)) ([6b5b9f7](https://github.com/windmill-labs/windmill/commit/6b5b9f72d4f9ce86b21d8d21ae342b6c8dc14b93))
+
+## [1.790.1](https://github.com/windmill-labs/windmill/compare/v1.790.0...v1.790.1) (2026-08-17)
+
+
+### Bug Fixes
+
+* fall back to polling when a proxy mutes the job SSE stream ([#10716](https://github.com/windmill-labs/windmill/issues/10716)) ([64d78b4](https://github.com/windmill-labs/windmill/commit/64d78b4db1d7d939c598c86d1998218d52fbcc21))
+
+
+### Performance Improvements
+
+* cap resource content sent to the search modal ([#10714](https://github.com/windmill-labs/windmill/issues/10714)) ([529e960](https://github.com/windmill-labs/windmill/commit/529e9606297ee0b41456a66222f31409d7bc7669))
+* unblock workers before the API router is built ([#10711](https://github.com/windmill-labs/windmill/issues/10711)) ([0258f3f](https://github.com/windmill-labs/windmill/commit/0258f3f81b96bb8d4e343ba8aeba614f9c836579))
+
+## [1.790.0](https://github.com/windmill-labs/windmill/compare/v1.789.0...v1.790.0) (2026-08-15)
+
+
+### Features
+
+* add trigger_history table with source tracking ([#10696](https://github.com/windmill-labs/windmill/issues/10696)) ([633d7bc](https://github.com/windmill-labs/windmill/commit/633d7bcb2ea034c39b72f7a8f5109b4ccd71b0be))
+* advertise the pinned artifact version in get_preview_status ([#10691](https://github.com/windmill-labs/windmill/issues/10691)) ([850b028](https://github.com/windmill-labs/windmill/commit/850b028778afe0cda1dc357c9e647d066339f3ce))
+* **ai-sessions:** add plan mode ([#10057](https://github.com/windmill-labs/windmill/issues/10057)) ([caa1898](https://github.com/windmill-labs/windmill/commit/caa189868c6ec9ebc2b6311e07308a83fa25ad8d))
+* let the global AI chat call connected MCP servers as the user ([#10656](https://github.com/windmill-labs/windmill/issues/10656)) ([3f07a1a](https://github.com/windmill-labs/windmill/commit/3f07a1a803a3f8a176de754188f641bdfcaa6cec))
+* stream audit logs in batches when a page is slow to load ([#10695](https://github.com/windmill-labs/windmill/issues/10695)) ([9334727](https://github.com/windmill-labs/windmill/commit/9334727d99eac251b0a995916c7ea00bd9596cef))
+* **telemetry:** extend feature-usage tracking beyond AI features ([#10681](https://github.com/windmill-labs/windmill/issues/10681)) ([53eb946](https://github.com/windmill-labs/windmill/commit/53eb94659bd27e75ed4acf4ce414046ac8df4cc8))
+
+
+### Bug Fixes
+
+* **agents:** let the scratch-dir hooks own their permission prompt ([#10702](https://github.com/windmill-labs/windmill/issues/10702)) ([0a40b38](https://github.com/windmill-labs/windmill/commit/0a40b3806fc08c6d7b2f9fd9b7ade07fdf1841f1))
+* **agents:** stop the scratch-dir guards prompting on quoted text ([#10703](https://github.com/windmill-labs/windmill/issues/10703)) ([e6e2e53](https://github.com/windmill-labs/windmill/commit/e6e2e53e97bebd407d72819d6163c04b6fc0b6b0))
+* **ci:** use random delimiters for untrusted multiline workflow values ([#10706](https://github.com/windmill-labs/windmill/issues/10706)) ([0fc74de](https://github.com/windmill-labs/windmill/commit/0fc74dec5f9d9d6594f1bc4a85b895ee8c3bf17b))
+* confine jobs:run tokens to the jobs of the runnables they may start ([#10635](https://github.com/windmill-labs/windmill/issues/10635)) ([ee53327](https://github.com/windmill-labs/windmill/commit/ee533273dd2fa0dc70e45b9750f3556002b150b7))
+* drop sampling params on Claude models that reject them ([#10708](https://github.com/windmill-labs/windmill/issues/10708)) ([3468cb6](https://github.com/windmill-labs/windmill/commit/3468cb68b12c27f7f133e350b433fd9379fc8b07))
+* **groups:** replace instance-group delta-patching with a state-based reconciler ([#10686](https://github.com/windmill-labs/windmill/issues/10686)) ([b551033](https://github.com/windmill-labs/windmill/commit/b5510333eac99f575aa2251398ca58626e419968))
+* keep a resource's linked secret reference in sync while renaming ([#10693](https://github.com/windmill-labs/windmill/issues/10693)) ([60c5ad2](https://github.com/windmill-labs/windmill/commit/60c5ad252afe23642410743632be2f6eaf2fbffd))
+* keep non traffic-serving processes out of coordinated restarts ([#10694](https://github.com/windmill-labs/windmill/issues/10694)) ([6d03784](https://github.com/windmill-labs/windmill/commit/6d03784d4b15535666bd4afdc5bbde5af016e078))
+* recover from a refused mcp read assertion, drop stale discovery ([#10710](https://github.com/windmill-labs/windmill/issues/10710)) ([effdcd9](https://github.com/windmill-labs/windmill/commit/effdcd99155a9235856b245b7f49a37e0632db08))
+* refresh AI provider model defaults and capability metadata ([#10690](https://github.com/windmill-labs/windmill/issues/10690)) ([68fc782](https://github.com/windmill-labs/windmill/commit/68fc7825bb5cd04347debb1a30af227614b9d9f5))
+* send sage_intacct oauth client credentials in the request body ([#10685](https://github.com/windmill-labs/windmill/issues/10685)) ([bd5b3ea](https://github.com/windmill-labs/windmill/commit/bd5b3ea779fa6351e937fc3639f0bb985ffc1ce9))
+
+
+### Performance Improvements
+
+* back off the interactive worker shell under EXIT_AFTER_N_JOBS ([#10700](https://github.com/windmill-labs/windmill/issues/10700)) ([578d5e9](https://github.com/windmill-labs/windmill/commit/578d5e9a7d1016deaa81e5bf314029c5d7de9589))
+* cache resolved python interpreter path across worker restarts ([#10701](https://github.com/windmill-labs/windmill/issues/10701)) ([878b8ef](https://github.com/windmill-labs/windmill/commit/878b8ef4c47f650686d4a42fc9763d57cc1c62cf))
+* declare a settings pass instead of reading one setting at a time ([#10698](https://github.com/windmill-labs/windmill/issues/10698)) ([30f5d2e](https://github.com/windmill-labs/windmill/commit/30f5d2e7660ad5bfa335d76a69bd5c3ad8e70c77))
+* resolve the worker external IP in the background ([#10697](https://github.com/windmill-labs/windmill/issues/10697)) ([22eadab](https://github.com/windmill-labs/windmill/commit/22eadab67d52fe4cb6bf1e73d161a6a439770295))
+
## [1.789.0](https://github.com/windmill-labs/windmill/compare/v1.788.0...v1.789.0) (2026-08-13)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000000..e3bc91fd77
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,34 @@
+# Contributing to Windmill
+
+At this time, we are not seeking outside contribution.
+
+AI has made writing code easy. The hard part, today, is not writing the code, but reviewing it,
+making sure quality stays high, and keeping the product coherent. In that light, unfortunately,
+external code contributions are "donating" the easy part of the job, while creating more of the
+hard work.
+
+With that said, we are happy to accept small, trivially-verified PRs that fix a problem. However,
+we ask that you refrain from submitting low-value PRs (e.g. typo fixes) or PRs that are more than a
+dozen or so lines. Such PRs will be closed with a reference to this guideline.
+
+If you have a big idea you'd like us to consider, feel free to open a
+[feature request](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md)
+about it.
+
+This policy may change in the future as the project matures. Until then, thank you for your
+understanding.
+
+## What is still very welcome
+
+- [Bug reports](https://github.com/windmill-labs/windmill/issues/new?template=bug_report.yml), with
+ clear reproduction steps.
+- [Feature requests](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md),
+ including for ideas too big to be a PR.
+- Questions and feedback on [Discord](https://discord.gg/V7PM2YHsPB).
+- Contributions to the [Windmill Hub](https://hub.windmill.dev), where scripts, flows and apps are
+ shared with the community.
+
+## If you do open a PR
+
+Small, self-contained fixes are still accepted. They require signing the
+[CLA](./CLA.md), which the CLA bot will prompt for on your first PR.
diff --git a/README.md b/README.md
index 2d1d4d62ac..71d4d39a19 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ Scripts are turned into sharable UIs automatically, and can be composed together
- Try it - Website - Docs - Discord - Hub - Contributor's guide
+ Try it - Website - Docs - Discord - Hub - Contributing
# Windmill - Developer platform for APIs, background jobs, workflows and UIs
@@ -62,6 +62,7 @@ https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4
- [Run a local dev setup](#run-a-local-dev-setup)
- [Frontend only](#frontend-only)
- [Backend + Frontend](#backend--frontend)
+ - [Contributing](#contributing)
- [Contributors](#contributors)
- [Copyright](#copyright)
@@ -260,7 +261,7 @@ On self-hosted instances, you might want to import all the approved resource typ
| NATIVE_MODE | false | Enable native mode: sets NUM_WORKERS=8, rejects non-native jobs (nativets, postgresql, mysql, etc.) | Worker |
| SLEEP_QUEUE | 50 | The number of ms to sleep in between the last check for new jobs in the DB. It is multiplied by NUM_WORKERS such that in average, for one worker instance, there is one pull every SLEEP_QUEUE ms. | Worker |
| KEEP_JOB_DIR | false | Keep the job directory after the job is done. Useful for debugging. | Worker |
-| EXIT_AFTER_N_JOBS | None | Exit the worker process after it has executed that many jobs, so that a supervisor restarts it and no process runs more than that many, bar the steps of a same-worker flow it has started, which it always finishes (set it to 1 for a process per job; jobs handed to a dedicated worker, and the worker's own init and periodic scripts, do not count). For deployments that isolate executions by process lifetime rather than with nsjail; note that a container restart resets the process, not the container filesystem, so caches and `/tmp` survive it. The worker name is then derived from the hostname instead of being random, so the restarted worker keeps its row in the workers list (an agent worker keeps the row but restarts its job count). Use one worker per process: workers of one process share its environment, so the first to reach the limit shuts the others down too. | Worker |
+| EXIT_AFTER_N_JOBS | None | Exit the worker process after it has executed that many jobs, so that a supervisor restarts it and no process runs more than that many, bar the steps of a same-worker flow it has started, which it always finishes (set it to 1 for a process per job; jobs handed to a dedicated worker, and the worker's own init and periodic scripts, do not count). Not counting the init and periodic scripts means they run again on every restart: an init script's runtime is added to the latency of every batch of that many jobs, and a periodic script fires once per process start whatever its interval says. The worker's shell in the workers page also starts backed off rather than after the two minutes it otherwise takes, since a process due to be recycled cannot count on living that long: the first command of a session can wait up to 15s, later ones are immediate. For deployments that isolate executions by process lifetime rather than with nsjail; note that a container restart resets the process, not the container filesystem, so caches and `/tmp` survive it. The worker name is then derived from the hostname instead of being random, so the restarted worker keeps its row in the workers list (an agent worker keeps the row but restarts its job count). Use one worker per process: workers of one process share its environment, so the first to reach the limit shuts the others down too. | Worker |
| WORKER_SUFFIX | None | Pins the last part of the worker name, which is otherwise random, so that a restarted worker keeps its row in the workers list. Only needed when several worker processes of the same worker group run on one host, since the name is derived from the hostname: give each of them a distinct value, as two processes sharing one must never happen. At most 64 letters, digits and underscores; anything else is refused at startup. | Worker |
| LICENSE_KEY (EE only) | None | License key checked at startup for the Enterprise Edition of Windmill | Worker |
| SLACK_SIGNING_SECRET | None | The signing secret of your Slack app. See [Slack documentation](https://api.slack.com/authentication/verifying-requests-from-slack) | Server |
@@ -329,6 +330,12 @@ running options.
2. You can specify any feature flag you want to enable, for example `cargo run --features python` to enable the python executor.
7. Windmill should be available at `http://localhost:3000`
+## Contributing
+
+At this time, we are not seeking outside contribution. Bug reports and feature requests remain very
+welcome, and small, trivially-verified PRs that fix a problem are still accepted. See
+[CONTRIBUTING.md](./CONTRIBUTING.md) for the full policy.
+
## Contributors
diff --git a/ai_evals/README.md b/ai_evals/README.md
index 08725e8aa8..ee8f730fc3 100644
--- a/ai_evals/README.md
+++ b/ai_evals/README.md
@@ -150,6 +150,21 @@ Global initial fixtures can also seed `liveEditorDrafts` with `type`,
currently open script, flow, or raw app editor so cases can test prompts that
refer to "this" or the "current" item.
+Global initial fixtures can seed the session's `artifacts` — `{ name, versions: [{ content,
+note? }], role?, approvedVersion? }`, oldest version first, so the artifact starts with the
+history `list_artifact_versions` reports — and the `previewTabs` open in its side panel, for
+cases that run with `runtime.sessionChat: true`. A tab entry names one destination and may
+be the `active` one:
+
+```json
+"previewTabs": [{ "artifact": { "name": "Onboarding plan", "version": 2 }, "active": true }]
+```
+
+`page` (`{ href, label }`) and `item` (`{ kind, path }`) tabs work the same way. Tabs are
+driven by the production tab model, so `open_preview`, `get_preview_status` and
+`close_page` really open, report and close them, and a `version` is the pin a reader
+chose in the artifact's version picker — which only `get_preview_status` reports.
+
Global initial fixtures can seed `workspace.variables` with
`{ path, value, is_secret, description?, labels?, ws_specific? }` entries so cases can
read and edit variables that already exist in the workspace. The mock mirrors the real
@@ -265,6 +280,10 @@ Typical artifacts by mode:
- `history/`: optional tracked pass-rate history written by `run --record`, one JSONL file per mode
- `results/`: local benchmark output and artifacts
+Harness unit tests run in two lanes: `bun test adapters/` for plain TypeScript, and
+`bun run test:frontend-graph` for `*.vitest.ts` files, which exercise adapters built on
+frontend code (Svelte runes, SvelteKit aliases) that bun cannot load.
+
## Notes
- Frontend modes reuse the production frontend chat code through the Vitest bridge.
diff --git a/ai_evals/adapters/frontend/core/global/evalArtifactHelpers.test.ts b/ai_evals/adapters/frontend/core/global/evalArtifactHelpers.test.ts
index c74f341735..99ac50ca98 100644
--- a/ai_evals/adapters/frontend/core/global/evalArtifactHelpers.test.ts
+++ b/ai_evals/adapters/frontend/core/global/evalArtifactHelpers.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import { createEvalArtifactHelpers } from "./evalArtifactStore";
+import { planArtifactId } from "../../../../../frontend/src/lib/components/copilot/chat/artifacts/planIdentity";
// 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
@@ -19,4 +20,20 @@ describe("eval artifact store", () => {
expect(typeof (helpers.artifacts as any)[method]).toBe("function");
}
});
+
+ it("files a plan under the id production derives, seeded or created", async () => {
+ const { helpers, sessionId } = createEvalArtifactHelpers([
+ { name: "Seeded plan", role: "plan", versions: [{ content: "v1" }] },
+ ]);
+ const seeded = await helpers.artifacts.listForSession(sessionId);
+ expect(seeded.map((a: any) => a.id)).toEqual([planArtifactId(sessionId)]);
+
+ const other = createEvalArtifactHelpers();
+ const created = await other.helpers.artifacts.create(other.sessionId, {
+ name: "Plan",
+ content: "v1",
+ role: "plan",
+ });
+ expect(created.id).toBe(planArtifactId(other.sessionId));
+ });
});
diff --git a/ai_evals/adapters/frontend/core/global/evalArtifactStore.ts b/ai_evals/adapters/frontend/core/global/evalArtifactStore.ts
index ed67cce468..f2d77f64e0 100644
--- a/ai_evals/adapters/frontend/core/global/evalArtifactStore.ts
+++ b/ai_evals/adapters/frontend/core/global/evalArtifactStore.ts
@@ -1,11 +1,74 @@
+import { planArtifactId } from "../../../../../frontend/src/lib/components/copilot/chat/artifacts/planIdentity";
+
// 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() {
+// Cases run concurrently in one process and the preview handlers are registered
+// process-wide, keyed by session id — so each run needs its own.
+let sessionSeq = 0;
+
+/** An artifact the session already holds when the case starts: history has to predate the
+ * run, since one prompt cannot both build a past and reason about it. */
+export interface SeededArtifact {
+ name: string;
+ role?: "plan";
+ /** Which version the user agreed to. Below the last one means the current text is a
+ * proposal they turned down, which is the state worth seeding. */
+ approvedVersion?: number;
+ /** Oldest first; the last one is the artifact's current content. */
+ versions: Array<{ content: string; note?: string }>;
+}
+
+export function createEvalArtifactHelpers(seed: SeededArtifact[] = []) {
+ const sessionId = `eval-session-${sessionSeq++}`;
const items = new Map>();
// Snapshots per artifact id, oldest first — the version tools read history from here.
const history = new Map>>();
+ // How a preview-tab fixture names the artifact its tab shows.
+ const seededIds = new Map();
let seq = 0;
+ for (const entry of seed) {
+ // Derived, not minted: the tools that must not touch the plan recognise it by this id, so
+ // an id of the harness's own would pass a case the real gate refuses. The counter advances
+ // either way, or seeding a plan would renumber the rows around it and collapse the update
+ // order they are sorted on.
+ const n = seq++;
+ const id = entry.role === "plan" ? planArtifactId(sessionId) : `eval-artifact-${n}`;
+ const current = entry.versions.at(-1);
+ if (!current) continue;
+ // A preview tab names the artifact it shows, so a shared name would open whichever
+ // one happened to be seeded last.
+ if (seededIds.has(entry.name)) {
+ throw new Error(
+ `Two seeded artifacts are named "${entry.name}" — a preview tab fixture could not tell them apart`,
+ );
+ }
+ seededIds.set(entry.name, id);
+ items.set(id, {
+ id,
+ sessionId,
+ chatId: "eval-chat",
+ kind: "md",
+ name: entry.name,
+ content: current.content,
+ role: entry.role,
+ approvedVersion: entry.approvedVersion,
+ createdAt: 0,
+ updatedAt: seq,
+ version: entry.versions.length,
+ });
+ history.set(
+ id,
+ entry.versions.map((v, i) => ({
+ key: `${id}:${i + 1}`,
+ artifactId: id,
+ version: i + 1,
+ name: entry.name,
+ content: v.content,
+ savedAt: i,
+ note: v.note,
+ })),
+ );
+ }
const snapshotOf = (
artifact: Record,
version: number,
@@ -21,14 +84,31 @@ export function createEvalArtifactHelpers() {
});
const store = {
create: async (sessionId: string, input: Record) => {
+ // One plan per session, as SessionArtifactsStore enforces it — the tool refuses
+ // first, so reaching this means a case drove create_artifact past that message.
+ if (
+ input.role === "plan" &&
+ [...items.values()].some(
+ (a) => a.sessionId === sessionId && a.role === "plan",
+ )
+ ) {
+ throw new Error(`Session ${sessionId} already has a plan document`);
+ }
const now = seq++;
const artifact = {
- id: `eval-artifact-${now}`,
+ id:
+ input.role === "plan"
+ ? planArtifactId(sessionId)
+ : `eval-artifact-${now}`,
sessionId,
chatId: input.chatId,
kind: input.kind ?? "md",
name: input.name,
content: input.content,
+ // The plan document is only distinguishable by these, both in the snapshot the
+ // judge reads and in what list_artifacts reports back to the model.
+ role: input.role,
+ approvedVersion: input.approvedVersion,
createdAt: now,
updatedAt: now,
version: 1,
@@ -58,6 +138,15 @@ export function createEvalArtifactHelpers() {
...existing,
name: input.name ?? existing.name,
content: input.content ?? existing.content,
+ // Carried only onto a version this write produced, as SessionArtifactsStore does:
+ // a rename cannot promote a proposal the user turned down.
+ approvedVersion:
+ input.approvedVersion ??
+ (input.keepApproved &&
+ existing.approvedVersion !== undefined &&
+ contentChanged
+ ? version
+ : existing.approvedVersion),
updatedAt: seq++,
version,
};
@@ -84,10 +173,12 @@ export function createEvalArtifactHelpers() {
return {
helpers: {
artifacts: store,
- sessionId: EVAL_SESSION_ID,
+ sessionId,
getChatId: () => "eval-chat",
- openArtifact: () => {},
+ openArtifact: (_id: string, _name: string) => {},
},
+ sessionId,
+ seededIds,
snapshot: () => [...items.values()],
};
}
diff --git a/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts b/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts
new file mode 100644
index 0000000000..f32bf1e22e
--- /dev/null
+++ b/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts
@@ -0,0 +1,188 @@
+import {
+ setClosePreviewTabsHandler,
+ setGetPreviewStatusHandler,
+ setOpenPagePreviewHandler,
+ setOpenPreviewHandler,
+} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
+import type { GlobalActivePreviewContext } from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
+import {
+ describePreview,
+ previewTargetForSessionTarget,
+ selectPreviewTabsToClose,
+ SessionPreviewTabs,
+ whereIs,
+} from "../../../../../frontend/src/lib/components/sessions/sessionPreviewTabs.svelte";
+import {
+ previewLocationContext,
+ previewLocationLabel,
+ promptSafe,
+ resolvePreviewTab,
+} from "../../../../../frontend/src/lib/components/sessions/previewRouter";
+import type { ArtifactVersionTarget } from "../../../../../frontend/src/lib/components/sessions/previewRouter";
+import type { SessionTarget } from "../../../../../frontend/src/lib/components/sessions/sessionState.svelte";
+
+// The side panel a session chat talks to, driven by the production tab model rather than
+// by canned tool results — so a case measures what the real open_preview / get_preview_status
+// / close_page report about the tabs the reader has. sessionRuntime.svelte.ts (the production
+// owner of these handlers) can't run here: it reaches for IndexedDB, stores and live editors.
+
+export interface EvalPreviewTabFixture {
+ /** Artifact tab, named by the artifact fixture it shows. `version` pins it, as a reader does. */
+ artifact?: { name: string; version?: number };
+ /** Workspace page tab, e.g. `{ href: "/runs", label: "Runs" }`. */
+ page?: { href: string; label: string };
+ /** Editor tab for a workspace item. */
+ item?: { kind: SessionTarget["kind"]; path: string };
+ /** Tab the reader is looking at. Defaults to the last seeded one. */
+ active?: boolean;
+}
+
+// Registered once for the whole process, as production does at module load, and dispatched
+// by session id: global cases run concurrently, so a per-run registration would have every
+// case answering out of whichever run registered last.
+const panels = new Map();
+
+const NO_SESSION = "No active session; the preview panel is unavailable.";
+
+function panelFor(sessionId: string | undefined): SessionPreviewTabs | undefined {
+ return sessionId ? panels.get(sessionId) : undefined;
+}
+
+setGetPreviewStatusHandler((sessionId) => {
+ const owner = panelFor(sessionId);
+ if (!owner) return NO_SESSION;
+ return describePreview(owner.tabs, owner.activeId, !!owner.displayedTab);
+});
+
+setOpenPreviewHandler(async ({ sessionId, kind, path }) => {
+ const owner = panelFor(sessionId);
+ if (!owner) return "Error: no active session to open the preview in.";
+ const target = previewTargetForSessionTarget(kind, path);
+ if (!target) {
+ return `Error: ${kind} targets cannot be shown in the preview panel.`;
+ }
+ // The pipeline branch of the production handler waits on an editor that only exists once
+ // a canvas mounts, which never happens here — a pipeline preview reports as any other.
+ const result = owner.open(target);
+ return result.status === "focused"
+ ? `A preview tab is already showing ${kind} "${path}" — focused it.`
+ : `Opened ${kind} preview for ${path} in a new tab in the side panel.`;
+});
+
+setOpenPagePreviewHandler(({ sessionId, href, label, newTab }) => {
+ const owner = panelFor(sessionId);
+ if (!owner) return undefined;
+ const result = owner.open({ type: "page", href, label }, { forceNewTab: newTab });
+ if (result.status === "focused") {
+ return `A preview tab is already showing ${label} — focused it.`;
+ }
+ if (result.status === "retargeted") {
+ return `Updated the ${label} preview tab with the requested view.`;
+ }
+ return `Opened ${label} in a new preview tab in the side panel.`;
+});
+
+setClosePreviewTabsHandler(({ sessionId, all, match }) => {
+ const owner = panelFor(sessionId);
+ if (!owner) return NO_SESSION;
+ if (owner.tabs.length === 0) return "The preview panel has no open tabs.";
+ const labelFor = (t: (typeof owner.tabs)[number]) =>
+ promptSafe(previewLocationLabel(whereIs(t)));
+ const doomed = selectPreviewTabsToClose(owner.tabs, { all, match });
+ if (doomed.length === 0) {
+ return `No open tab matched "${match}". Open tabs: ${owner.tabs.map(labelFor).join(", ")}.`;
+ }
+ const closedLabels = doomed.map(labelFor);
+ for (const t of doomed) owner.close(t.id);
+ return `Closed ${closedLabels.length} preview tab${closedLabels.length === 1 ? "" : "s"} (${closedLabels.join(", ")}).`;
+});
+
+export interface EvalPreviewPanel {
+ /** Mirrors production: a written artifact is shown in the panel. `version` carries the
+ * caller's intent for the version picker — `latest` drops a pin the reader had set. */
+ openArtifact: (id: string, name: string, version?: ArtifactVersionTarget) => void;
+ /** What the user message stamps as ACTIVE PREVIEW, as sessionRuntime's resolver reads it. */
+ activePreview: () => GlobalActivePreviewContext | undefined;
+ dispose: () => void;
+}
+
+export function createEvalPreviewPanel(input: {
+ sessionId: string;
+ tabs: EvalPreviewTabFixture[];
+ /** Artifact ids by name, from the artifact fixture seeding. */
+ artifactIds: Map;
+}): EvalPreviewPanel {
+ // Nothing durable to write back to, and no debounce worth waiting on.
+ const owner = new SessionPreviewTabs(
+ { tabs: [], activeId: "", collapsed: false },
+ { persist: () => {} },
+ 0,
+ );
+ // Opening a tab makes it the active one, so the fixture's pick can only be applied once
+ // every tab is seeded — selecting inside the loop would lose to the next open.
+ let requestedActive: string | undefined;
+ for (const fixture of input.tabs) {
+ const opened = seedTab(owner, fixture, input.artifactIds);
+ if (opened && fixture.active) requestedActive = opened;
+ }
+ if (requestedActive) owner.select(requestedActive);
+ // Registered last: seeding throws on a malformed fixture, and this map outlives the run.
+ panels.set(input.sessionId, owner);
+
+ return {
+ openArtifact: (id, name, version) => {
+ owner.open({ type: "artifact", id, name, version });
+ },
+ activePreview: () => {
+ const tab = owner.displayedTab;
+ if (!tab) return undefined;
+ // Artifact and editor tabs are not iframes: they carry no page location, and an
+ // artifact's pinned version reaches the chat only through get_preview_status.
+ if (resolvePreviewTab(tab.url).kind !== "iframe") return undefined;
+ return previewLocationContext(whereIs(tab));
+ },
+ dispose: () => {
+ panels.delete(input.sessionId);
+ },
+ };
+}
+
+// Seeds one tab through the production open path and returns its id, so a fixture cannot
+// describe a tab the panel could not have reached on its own.
+function seedTab(
+ owner: SessionPreviewTabs,
+ fixture: EvalPreviewTabFixture,
+ artifactIds: Map,
+): string | undefined {
+ // A tab shows one destination; the branches below would silently keep the first.
+ const named = [fixture.artifact, fixture.page, fixture.item].filter(Boolean);
+ if (named.length > 1) {
+ throw new Error(
+ "Preview tab fixture sets more than one of artifact, page and item — a tab shows one of them",
+ );
+ }
+ if (fixture.artifact) {
+ const id = artifactIds.get(fixture.artifact.name);
+ if (!id) {
+ throw new Error(
+ `Preview tab fixture references artifact "${fixture.artifact.name}", which no artifact fixture seeds`,
+ );
+ }
+ owner.open({ type: "artifact", id, name: fixture.artifact.name });
+ // A pin is the reader's own pick in the version picker, never a side effect of opening.
+ if (fixture.artifact.version !== undefined) {
+ owner.pinArtifactVersion(id, fixture.artifact.version);
+ }
+ } else if (fixture.page) {
+ owner.open({ type: "page", href: fixture.page.href, label: fixture.page.label });
+ } else if (fixture.item) {
+ const target = previewTargetForSessionTarget(fixture.item.kind, fixture.item.path);
+ if (!target) {
+ throw new Error(`Preview tab fixture has an unpreviewable item kind: ${fixture.item.kind}`);
+ }
+ owner.open(target);
+ } else {
+ throw new Error("Preview tab fixture must set one of artifact, page or item");
+ }
+ return owner.activeId;
+}
diff --git a/ai_evals/adapters/frontend/core/global/evalPreviewTabs.vitest.ts b/ai_evals/adapters/frontend/core/global/evalPreviewTabs.vitest.ts
new file mode 100644
index 0000000000..b9a2990a0a
--- /dev/null
+++ b/ai_evals/adapters/frontend/core/global/evalPreviewTabs.vitest.ts
@@ -0,0 +1,37 @@
+import { expect, it, vi } from 'vitest'
+
+// The panel pulls in the global tool module, which reaches the editor stack it never uses here.
+vi.mock('monaco-editor', () => ({
+ editor: {},
+ languages: {},
+ KeyCode: {},
+ Uri: { parse: (value: string) => ({ toString: () => value }) },
+ MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 }
+}))
+vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({
+ getTypeScriptWorker: async () => async () => ({}),
+ typescriptVersion: 'test'
+}))
+vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) }))
+vi.mock('$lib/components/vscode', () => ({}))
+
+const { createEvalPreviewPanel } = await import('./evalPreviewTabs')
+
+// Every open makes its own tab active, so a fixture's `active` flag only means anything if
+// it survives the tabs seeded after it. Lose that and a case still runs — against a panel
+// state its author never described.
+it('keeps the tab a fixture marks active, not the last one seeded', () => {
+ const panel = createEvalPreviewPanel({
+ sessionId: 'eval-preview-tabs-unit-test',
+ tabs: [
+ { page: { href: '/runs', label: 'Runs' }, active: true },
+ { artifact: { name: 'Onboarding plan' } }
+ ],
+ artifactIds: new Map([['Onboarding plan', 'eval-artifact-0']])
+ })
+ try {
+ expect(panel.activePreview()?.location).toBe('/runs')
+ } finally {
+ panel.dispose()
+ }
+})
diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts
index dcd771663a..d35d874f71 100644
--- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts
+++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts
@@ -12,9 +12,19 @@ import {
getGlobalDraft,
listGlobalDrafts,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter";
+import { appendPlanModeInstructions } from "../../../../../frontend/src/lib/components/copilot/chat/planMode";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
+import { createEvalPlanTools } from "./planModeTools";
import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte";
-import { createEvalArtifactHelpers } from "./evalArtifactStore";
+import {
+ createEvalArtifactHelpers,
+ type SeededArtifact,
+} from "./evalArtifactStore";
+import {
+ createEvalPreviewPanel,
+ type EvalPreviewPanel,
+ type EvalPreviewTabFixture,
+} from "./evalPreviewTabs";
import type { ModeRunContext } from "../../../../core/types";
import type { GlobalDraftState } from "../../../../core/validators";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
@@ -83,11 +93,18 @@ export interface GlobalEvalOptions {
user?: GlobalUserFixture;
// Emulate a session chat (preview tools + session prompt); default false = standalone baseline.
sessionChat?: boolean;
+ // Start in plan mode: the gate refuses every tool without `planModeSafe`, and the two plan
+ // tools are offered. Needs sessionChat, which is what plan mode is gated on in production.
+ planMode?: boolean;
model?: string;
maxIterations?: number;
provider?: AIProvider;
backend: WindmillBackendSettings;
workspaceRoot?: string;
+ // Artifacts the session already holds when the run starts.
+ artifacts?: SeededArtifact[];
+ /** Tabs already open in the side panel, including any artifact version the reader pinned. */
+ previewTabs?: EvalPreviewTabFixture[];
runContext?: ModeRunContext;
}
@@ -106,27 +123,67 @@ export async function runGlobalEval(
options.workspaceFixtures ?? {},
);
seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
+ // Declared out here only so `finally` can reach it; a malformed fixture throws while
+ // building it, and everything seeded above still has to be torn down.
+ let panel: EvalPreviewPanel | undefined;
try {
+ const evalArtifacts = createEvalArtifactHelpers(options.artifacts);
+ // Only a session chat has a side panel, so only it gets one here. Seeded tabs would
+ // otherwise vanish without a word, and the case would measure an empty panel.
+ if (!options.sessionChat && options.previewTabs?.length) {
+ throw new Error(
+ "This fixture seeds previewTabs, which only a session chat has — set runtime.sessionChat: true on the case.",
+ );
+ }
+ if (options.sessionChat) {
+ panel = createEvalPreviewPanel({
+ sessionId: evalArtifacts.sessionId,
+ tabs: options.previewTabs ?? [],
+ artifactIds: evalArtifacts.seededIds,
+ });
+ }
const model = options.model ?? "claude-haiku-4-5-20251001";
const injectActiveEditorContext =
process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1";
+ const planMode = options.planMode
+ ? createEvalPlanTools({
+ create: evalArtifacts.helpers.artifacts.create,
+ sessionId: evalArtifacts.helpers.sessionId,
+ chatId: evalArtifacts.helpers.getChatId(),
+ })
+ : undefined;
// 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 baseSystemMessage = prepareGlobalSystemMessage(undefined, {
+ user: options.user,
+ previewTools: options.sessionChat ?? false,
+ });
const rawResult = await runEval({
userPrompt,
- systemMessage: prepareGlobalSystemMessage(undefined, {
- user: options.user,
- previewTools: options.sessionChat ?? false,
+ systemMessage: baseSystemMessage,
+ // Re-derived per request, as production's getter is: the instructions have to leave
+ // the prompt when the plan is approved, or the model is still told it may not build
+ // while the gate has already opened.
+ getSystemMessage: planMode
+ ? () =>
+ planMode.isPlanModeActive()
+ ? appendPlanModeInstructions(baseSystemMessage, 0)
+ : baseSystemMessage
+ : undefined,
+ isPlanModeActive: planMode?.isPlanModeActive,
+ isToolAvailable: planMode?.isToolAvailable,
+ userMessage: prepareGlobalUserMessage(userPrompt, [], {
+ ...(injectActiveEditorContext ? { workspace: workspaceRoot } : {}),
+ activePreview: panel?.activePreview(),
}),
- userMessage: prepareGlobalUserMessage(
- userPrompt,
- [],
- injectActiveEditorContext ? { workspace: workspaceRoot } : {},
- ),
- tools: getGlobalEvalTools(options.sessionChat ?? false),
- helpers: evalArtifacts.helpers,
+ tools: [
+ ...getGlobalEvalTools(options.sessionChat ?? false),
+ ...(planMode?.tools ?? []),
+ ],
+ helpers: panel
+ ? { ...evalArtifacts.helpers, openArtifact: panel.openArtifact }
+ : evalArtifacts.helpers,
apiKey,
getOutput: async () => ({
...(await collectGlobalDraftState(workspaceRoot)),
@@ -159,6 +216,7 @@ export async function runGlobalEval(
finalContextTokens: rawResult.finalContextTokens,
};
} finally {
+ panel?.dispose();
clearGlobalDrafts(workspaceRoot);
clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
diff --git a/ai_evals/adapters/frontend/core/global/planModeTools.ts b/ai_evals/adapters/frontend/core/global/planModeTools.ts
new file mode 100644
index 0000000000..bea5538fc8
--- /dev/null
+++ b/ai_evals/adapters/frontend/core/global/planModeTools.ts
@@ -0,0 +1,69 @@
+import {
+ EXIT_PLAN_MODE_TOOL,
+ EXIT_PLAN_MODE_TOOL_DESCRIPTION,
+ derivePlanTitle,
+ exitPlanModeArgs,
+ planSummaryOf,
+} from "../../../../../frontend/src/lib/components/copilot/chat/planMode";
+import { PLAN_MODE_MESSAGES } from "../../../../../frontend/src/lib/components/copilot/chat/planModeMessages";
+import { createToolDef } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
+import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
+
+/**
+ * `exit_plan_mode` built from the production schema, description and messages, so a case
+ * exercises the real gate and wording with the posture living here rather than on the
+ * manager. It resolves immediately — the runners define no `requestConfirmation`, so the
+ * plan is always approved and a refused one cannot be expressed.
+ */
+export function createEvalPlanTools(artifacts: {
+ create: (
+ sessionId: string,
+ input: Record,
+ ) => Promise<{ id: string; name: string }>;
+ sessionId: string;
+ chatId: string;
+}): {
+ tools: ProductionTool<{}>[];
+ isPlanModeActive: () => boolean;
+ isToolAvailable: (name: string) => boolean;
+} {
+ let planActive = true;
+ return {
+ isPlanModeActive: () => planActive,
+ // Withdrawn on approval, as production's tool getter does it: leaving it advertised
+ // invites a second hand-over of a plan already agreed, which would write a duplicate.
+ // Production would offer enter_plan_mode in its place; these cases stop at the first
+ // hand-over, so a fresh planning round belongs to a case of its own.
+ isToolAvailable: (name) => name !== EXIT_PLAN_MODE_TOOL || planActive,
+ // Production offers one plan tool at a time and these cases start in plan mode, so
+ // enter_plan_mode would only invite a turn spent entering a posture already held.
+ tools: [
+ {
+ def: createToolDef(
+ exitPlanModeArgs,
+ EXIT_PLAN_MODE_TOOL,
+ EXIT_PLAN_MODE_TOOL_DESCRIPTION,
+ ),
+ // Carries the safety tag for the same reason production does: it is the only way out
+ // of the posture, so the gate must not refuse it.
+ planModeSafe: true,
+ fn: async ({ args }) => {
+ const summary = planSummaryOf(args);
+ if (!summary?.trim()) {
+ return PLAN_MODE_MESSAGES.missingSummary;
+ }
+ planActive = false;
+ await artifacts.create(artifacts.sessionId, {
+ name: derivePlanTitle(summary),
+ content: summary,
+ kind: "md",
+ role: "plan",
+ approvedVersion: 1,
+ chatId: artifacts.chatId,
+ });
+ return PLAN_MODE_MESSAGES.approvedWithDoc;
+ },
+ },
+ ] as ProductionTool<{}>[],
+ };
+}
diff --git a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts
index f787743dfa..4501f24810 100644
--- a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts
+++ b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts
@@ -43,6 +43,15 @@ export interface RunEvalParams {
getOutput: () => TOutput | Promise;
/** Model and Windmill backend configuration */
options: EvalRunnerOptions;
+ /** Drives the production plan-mode gate in processToolCall. Absent leaves it inert,
+ * which is what every mode but an opted-in global case wants. */
+ isPlanModeActive?: () => boolean;
+ /** Which of `tools` the model is offered on this request. Absent offers all of them. */
+ isToolAvailable?: (name: string) => boolean;
+ /** Re-read before every request, as production's systemMessage getter is. Needed when a
+ * tool changes what the prompt should say — plan mode's instructions have to come back
+ * out once the plan is approved. Falls back to the fixed `systemMessage`. */
+ getSystemMessage?: () => ChatCompletionSystemMessageParam;
onAssistantMessageStart?: () => void;
onAssistantToken?: (token: string) => void;
onAssistantMessageEnd?: () => void;
@@ -68,6 +77,9 @@ export async function runEval(
onAssistantToken,
onAssistantMessageEnd,
onToolCall,
+ isPlanModeActive,
+ isToolAvailable,
+ getSystemMessage,
} = params;
let shouldEmitMessageStart = true;
@@ -119,6 +131,7 @@ export async function runEval(
} = {
setToolStatus: () => {},
removeToolStatus: () => {},
+ isPlanModeActive,
onNewToken: (token: string) => {
if (shouldEmitMessageStart) {
onAssistantMessageStart?.();
@@ -140,8 +153,17 @@ export async function runEval(
try {
const result = await runChatLoop({
messages,
- systemMessage,
- tools: wrappedTools,
+ get systemMessage() {
+ return getSystemMessage?.() ?? systemMessage;
+ },
+ // Re-derived per request, as `systemMessage` is: a tool the posture has withdrawn
+ // must leave the schema too, or the model keeps being offered a call the run has
+ // moved past — and the token counts a case reports include a tool it cannot use.
+ get tools() {
+ return isToolAvailable
+ ? wrappedTools.filter((t) => isToolAvailable(t.def.function.name))
+ : wrappedTools;
+ },
helpers,
abortController,
callbacks,
diff --git a/ai_evals/adapters/frontend/vitest.unit.config.ts b/ai_evals/adapters/frontend/vitest.unit.config.ts
new file mode 100644
index 0000000000..7c5a794508
--- /dev/null
+++ b/ai_evals/adapters/frontend/vitest.unit.config.ts
@@ -0,0 +1,31 @@
+import { fileURLToPath } from 'node:url'
+import frontendConfig from '../../../frontend/vite.config.js'
+
+// Harness unit tests that reach into the frontend module graph. They can't run under
+// `bun test` (Svelte runes and the SvelteKit aliases both need this build), so they are
+// named `*.vitest.ts` — bun's `*.test.ts` sweep skips them and this config claims them.
+const FRONTEND_VITE_CONFIG_PATH = fileURLToPath(new URL('../../../frontend/vite.config.js', import.meta.url))
+const FRONTEND_TEST_SETUP_PATH = fileURLToPath(
+ new URL('../../../frontend/src/lib/test-setup.ts', import.meta.url)
+)
+const UNIT_TESTS = fileURLToPath(new URL('./**/*.vitest.ts', import.meta.url))
+
+const config = {
+ ...frontendConfig,
+ test: {
+ ...frontendConfig.test,
+ projects: [
+ {
+ extends: FRONTEND_VITE_CONFIG_PATH,
+ test: {
+ name: 'server',
+ environment: 'node',
+ include: [UNIT_TESTS],
+ setupFiles: [FRONTEND_TEST_SETUP_PATH]
+ }
+ }
+ ]
+ }
+}
+
+export default config
diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml
index ec2260c0e2..54fae9b24f 100644
--- a/ai_evals/cases/global.yaml
+++ b/ai_evals/cases/global.yaml
@@ -1180,6 +1180,7 @@
- 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.
+ initial: ai_evals/fixtures/frontend/global/initial/preview_runs_tab.json
runtime:
maxTurns: 6
sessionChat: true
@@ -1233,6 +1234,32 @@
- creates one artifact and revises it rather than creating a second artifact
- each revision carries a short description of what changed
+# A reader who pins an older version in the artifact's picker is looking at something the
+# artifact tools never report: an artifact tab carries no ACTIVE PREVIEW section, so the pin
+# reaches the chat through get_preview_status alone. Asked what is on screen, the model has
+# to read the panel instead of answering from the artifact's own history.
+
+- id: global-artifact-pinned-version-question
+ prompt: |-
+ Which version of the onboarding plan am I looking at right now?
+ initial: ai_evals/fixtures/frontend/global/initial/artifact_onboarding_plan_pinned_v2.json
+ runtime:
+ maxTurns: 6
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsUsed:
+ - get_preview_status
+ forbiddenToolsUsed:
+ - create_artifact
+ - update_artifact
+ - deploy_workspace_item
+ skipJudge: true
+ judgeChecklist:
+ - answers that the panel is showing version 2, not the latest version 5
+ - does not edit or re-create the artifact
+
# --- 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
@@ -1756,8 +1783,55 @@
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
+ - the artifact is registered as the session's plan (role "plan"), not as an ordinary note - the user asked for the plan they will come back to and revise
- does not create a flow or script draft yet
+- id: global-planmode1-hands-over-a-plan
+ prompt: |-
+ Our support inbox is a mess. I want incoming emails triaged by urgency and routed to the
+ right team, with anything urgent also posted to Slack.
+ Work out how you'd build this in Windmill.
+ initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
+ runtime:
+ maxTurns: 10
+ sessionChat: true
+ planMode: true
+ # No draft assertion: approving the plan opens the gate mid-run, and building from there is
+ # what production asks for, so a draft is not a failure. The gate itself is covered by
+ # shared.test.ts; what only a real model can show is whether it researches and hands over a
+ # usable plan instead of guessing at one.
+ toolExpect:
+ requiredToolsUsed:
+ - exit_plan_mode
+ # Not "saves the plan as an artifact": exit_plan_mode writes it, so the harness would
+ # satisfy that on every run the tool is called at all — it grades itself, not the model.
+ judgeChecklist:
+ - the plan covers classifying an incoming email by urgency, routing it to a team, and posting urgent ones to Slack
+ - the plan is specific about what would be built in Windmill (a flow and its steps, or the scripts involved)
+
+- id: global-planmode2-sketches-while-planning
+ prompt: |-
+ We're moving our nightly CSV export off a schedule and onto a webhook the vendor calls
+ when their file is ready. Work out how you'd rebuild it in Windmill — and draw me the
+ shape of it before you write anything, I find that easier to react to than prose.
+ initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
+ runtime:
+ maxTurns: 10
+ sessionChat: true
+ planMode: true
+ # Both tools, because either alone is a different behaviour: create_artifact without
+ # exit_plan_mode means the model filed the plan as the drawing, and exit_plan_mode without
+ # create_artifact means the posture blocked the drawing it was asked for.
+ toolExpect:
+ requiredToolsUsed:
+ - create_artifact
+ - exit_plan_mode
+ judgeChecklist:
+ - saves a diagram of the proposed design as an artifact rather than only describing it in chat
+ - the diagram covers the webhook that starts the run and the steps that replace the nightly schedule
+ - hands the plan over with exit_plan_mode instead of leaving it in the artifact
+ - does not register the diagram as the session's plan document
+
- 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
diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts
index f1655bd18f..b5195443d0 100644
--- a/ai_evals/core/types.ts
+++ b/ai_evals/core/types.ts
@@ -33,6 +33,9 @@ export interface EvalCaseRuntimeSpec {
appContext?: EvalCaseRuntimeAppContextSpec;
// Global mode: run as a session chat (preview tools + session prompt) vs the standalone chat.
sessionChat?: boolean;
+ // Global session chats: start the case in plan mode, so the workspace-changing tools are
+ // refused until the model hands over a plan with exit_plan_mode.
+ planMode?: boolean;
}
export interface FlowValidationSpec {
diff --git a/ai_evals/fixtures/frontend/global/initial/artifact_onboarding_plan_pinned_v2.json b/ai_evals/fixtures/frontend/global/initial/artifact_onboarding_plan_pinned_v2.json
new file mode 100644
index 0000000000..dd4ab37873
--- /dev/null
+++ b/ai_evals/fixtures/frontend/global/initial/artifact_onboarding_plan_pinned_v2.json
@@ -0,0 +1,38 @@
+{
+ "user": {
+ "username": "admin",
+ "is_admin": true
+ },
+ "artifacts": [
+ {
+ "name": "Onboarding plan",
+ "versions": [
+ {
+ "content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Create the customer record\n- Send the welcome email\n"
+ },
+ {
+ "content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record\n- Send the welcome email\n",
+ "note": "Added domain verification"
+ },
+ {
+ "content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record\n- Send the welcome email\n- Schedule the 7-day check-in\n",
+ "note": "Added the 7-day check-in"
+ },
+ {
+ "content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record in the CRM\n- Send the welcome email\n- Schedule the 7-day check-in\n",
+ "note": "Named the CRM as the record store"
+ },
+ {
+ "content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record in the CRM\n- Send the welcome email\n- Schedule the 7-day check-in\n- Hand over to the account manager\n",
+ "note": "Added the account-manager handover"
+ }
+ ]
+ }
+ ],
+ "previewTabs": [
+ {
+ "artifact": { "name": "Onboarding plan", "version": 2 },
+ "active": true
+ }
+ ]
+}
diff --git a/ai_evals/fixtures/frontend/global/initial/preview_runs_tab.json b/ai_evals/fixtures/frontend/global/initial/preview_runs_tab.json
new file mode 100644
index 0000000000..565e5ede37
--- /dev/null
+++ b/ai_evals/fixtures/frontend/global/initial/preview_runs_tab.json
@@ -0,0 +1,12 @@
+{
+ "user": {
+ "username": "admin",
+ "is_admin": true
+ },
+ "previewTabs": [
+ {
+ "page": { "href": "/runs", "label": "Runs" },
+ "active": true
+ }
+ ]
+}
diff --git a/ai_evals/modes/global.ts b/ai_evals/modes/global.ts
index 5628c21d5d..ad93b09a55 100644
--- a/ai_evals/modes/global.ts
+++ b/ai_evals/modes/global.ts
@@ -6,9 +6,15 @@ import {
type GlobalLiveEditorDraftFixture,
type GlobalUserFixture,
} from "../adapters/frontend/core/global/globalEvalRunner";
+import type { SeededArtifact } from "../adapters/frontend/core/global/evalArtifactStore";
+import type { EvalPreviewTabFixture } from "../adapters/frontend/core/global/evalPreviewTabs";
import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend";
import type { FrontendEvalModelConfig } from "../core/models";
-import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types";
+import type {
+ BenchmarkArtifactFile,
+ GlobalValidationSpec,
+ ModeRunner,
+} from "../core/types";
import { validateGlobalState, type GlobalDraftState } from "../core/validators";
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
import { getFrontendApiKey } from "./frontendCommon";
@@ -17,6 +23,8 @@ export interface GlobalInitialFixture {
workspace?: BenchmarkWorkspaceRunnables;
liveEditorDrafts?: GlobalLiveEditorDraftFixture[];
user?: GlobalUserFixture;
+ artifacts?: SeededArtifact[];
+ previewTabs?: EvalPreviewTabFixture[];
}
export function createGlobalModeRunner(
@@ -41,7 +49,10 @@ export function createGlobalModeRunner(
workspaceFixtures: initial?.workspace,
liveEditorDrafts: initial?.liveEditorDrafts,
user: initial?.user,
+ artifacts: initial?.artifacts,
+ previewTabs: initial?.previewTabs,
sessionChat: context.evalCase?.runtime?.sessionChat,
+ planMode: context.evalCase?.runtime?.planMode,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
@@ -81,7 +92,9 @@ export function createGlobalModeRunner(
};
}
-async function loadGlobalInitialFixture(path: string): Promise {
+async function loadGlobalInitialFixture(
+ path: string,
+): Promise {
if ((await stat(path)).isDirectory()) {
const { initialFrontend, initialBackend, initialDatatables } =
await loadAppFixtureForEval(path);
@@ -104,14 +117,20 @@ async function loadGlobalInitialFixture(path: string): Promise {
+async function loadGlobalExpectedFixture(
+ path: string,
+): Promise {
return JSON.parse(await readFile(path, "utf8")) as GlobalDraftState;
}
diff --git a/ai_evals/package.json b/ai_evals/package.json
index b7fbeaa1a5..6720c910cb 100644
--- a/ai_evals/package.json
+++ b/ai_evals/package.json
@@ -4,7 +4,8 @@
"type": "module",
"scripts": {
"cli": "bun cli/index.ts",
- "typecheck": "tsc -p tsconfig.json"
+ "typecheck": "tsc -p tsconfig.json",
+ "test:frontend-graph": "cd ../frontend && node_modules/.bin/vitest run --project server --config ../ai_evals/adapters/frontend/vitest.unit.config.ts"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.25",
diff --git a/ai_evals/tsconfig.json b/ai_evals/tsconfig.json
index 7b06d5788a..50b2b1110d 100644
--- a/ai_evals/tsconfig.json
+++ b/ai_evals/tsconfig.json
@@ -14,6 +14,8 @@
],
"exclude": [
"./**/*.test.ts",
- "./adapters/frontend/vitest.config.ts"
+ "./**/*.vitest.ts",
+ "./adapters/frontend/vitest.config.ts",
+ "./adapters/frontend/vitest.unit.config.ts"
]
}
diff --git a/backend/.sqlx/query-a41c4cbaffdb714e4a963557de5a4011744d684eb24e03cb4beae6a512613159.json b/backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json
similarity index 80%
rename from backend/.sqlx/query-a41c4cbaffdb714e4a963557de5a4011744d684eb24e03cb4beae6a512613159.json
rename to backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json
index 0141c379b4..ceb05001e4 100644
--- a/backend/.sqlx/query-a41c4cbaffdb714e4a963557de5a4011744d684eb24e03cb4beae6a512613159.json
+++ b/backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12 WHERE worker = $6",
+ "query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip) WHERE worker = $6",
"describe": {
"columns": [],
"parameters": {
@@ -16,10 +16,11 @@
"Float4",
"Float4",
"Float4",
- "Bool"
+ "Bool",
+ "Varchar"
]
},
"nullable": []
},
- "hash": "a41c4cbaffdb714e4a963557de5a4011744d684eb24e03cb4beae6a512613159"
+ "hash": "0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626"
}
diff --git a/backend/.sqlx/query-0df1c23ac429b5807d42e1da5ee2e2176a8a774f107bece5804cc7b221365c89.json b/backend/.sqlx/query-0df1c23ac429b5807d42e1da5ee2e2176a8a774f107bece5804cc7b221365c89.json
new file mode 100644
index 0000000000..cc5e9a8d45
--- /dev/null
+++ b/backend/.sqlx/query-0df1c23ac429b5807d42e1da5ee2e2176a8a774f107bece5804cc7b221365c89.json
@@ -0,0 +1,58 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "WITH RECURSIVE job_tree AS (\n SELECT id, tag FROM v2_job WHERE id = $2 AND workspace_id = $1\n UNION\n SELECT j.id, j.tag FROM v2_job j JOIN job_tree t ON j.parent_job = t.id\n WHERE j.workspace_id = $1\n )\n SELECT\n a.path,\n a.kind AS \"kind!: windmill_common::assets::AssetKind\",\n -- Several jobs of the tree touch one asset, each recording its own\n -- access. A job that recorded none contributes nothing rather than\n -- erasing a sibling's, so an all-null group is the only unknown one.\n -- Grouping here, not in Rust, is what makes LIMIT count assets: the\n -- retention keeps up to ten job rows per asset.\n COALESCE(bool_or(a.usage_access_type IN ('r', 'rw')), false) AS \"any_read!\",\n COALESCE(bool_or(a.usage_access_type IN ('w', 'rw')), false) AS \"any_write!\"\n FROM asset a JOIN job_tree t ON a.usage_path = t.id::text\n WHERE a.workspace_id = $1 AND a.usage_kind = 'job'\n AND ($3::text[] IS NULL OR t.tag = ANY($3))\n GROUP BY a.path, a.kind\n ORDER BY a.path, a.kind\n LIMIT $4",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "path",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "kind!: windmill_common::assets::AssetKind",
+ "type_info": {
+ "Custom": {
+ "name": "asset_kind",
+ "kind": {
+ "Enum": [
+ "s3object",
+ "resource",
+ "variable",
+ "ducklake",
+ "datatable",
+ "volume",
+ "dbt"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 2,
+ "name": "any_read!",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 3,
+ "name": "any_write!",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Uuid",
+ "TextArray",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ null,
+ null
+ ]
+ },
+ "hash": "0df1c23ac429b5807d42e1da5ee2e2176a8a774f107bece5804cc7b221365c89"
+}
diff --git a/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json b/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json
new file mode 100644
index 0000000000..e8ffcfb95b
--- /dev/null
+++ b/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json
@@ -0,0 +1,26 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT 'schedule' AS \"kind!\", COUNT(*)::BIGINT AS \"count!\" FROM schedule\n UNION ALL SELECT 'http', COUNT(*)::BIGINT FROM http_trigger\n UNION ALL SELECT 'websocket', COUNT(*)::BIGINT FROM websocket_trigger\n UNION ALL SELECT 'kafka', COUNT(*)::BIGINT FROM kafka_trigger\n UNION ALL SELECT 'nats', COUNT(*)::BIGINT FROM nats_trigger\n UNION ALL SELECT 'postgres', COUNT(*)::BIGINT FROM postgres_trigger\n UNION ALL SELECT 'mqtt', COUNT(*)::BIGINT FROM mqtt_trigger\n UNION ALL SELECT 'sqs', COUNT(*)::BIGINT FROM sqs_trigger\n UNION ALL SELECT 'gcp', COUNT(*)::BIGINT FROM gcp_trigger\n UNION ALL SELECT 'azure', COUNT(*)::BIGINT FROM azure_trigger\n UNION ALL SELECT 'amqp', COUNT(*)::BIGINT FROM amqp_trigger\n UNION ALL SELECT 'email', COUNT(*)::BIGINT FROM email_trigger\n -- Grouped, not a single 'native' key: these fire as nextcloud/google/github,\n -- so a lone key would not line up with the `trigger`/`fired` series.\n UNION ALL SELECT service_name::text, COUNT(*)::BIGINT FROM native_trigger GROUP BY service_name\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "kind!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "count!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ null,
+ null
+ ]
+ },
+ "hash": "1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e"
+}
diff --git a/backend/.sqlx/query-2132a715995f2775917c59c01ba66f2e472a2347f25cc7109f36a805806ee6e2.json b/backend/.sqlx/query-2132a715995f2775917c59c01ba66f2e472a2347f25cc7109f36a805806ee6e2.json
deleted file mode 100644
index 673b8f4574..0000000000
--- a/backend/.sqlx/query-2132a715995f2775917c59c01ba66f2e472a2347f25cc7109f36a805806ee6e2.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT username, added_via\n FROM usr\n WHERE workspace_id = $1 AND email = $2\n AND added_via->>'source' = 'instance_group'\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "username",
- "type_info": "Varchar"
- },
- {
- "ordinal": 1,
- "name": "added_via",
- "type_info": "Jsonb"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "Text"
- ]
- },
- "nullable": [
- false,
- true
- ]
- },
- "hash": "2132a715995f2775917c59c01ba66f2e472a2347f25cc7109f36a805806ee6e2"
-}
diff --git a/backend/.sqlx/query-9530b234b2ac8f7360552be5e6bb25270e6b4185ce5c7f3c6edd9fbaabf77544.json b/backend/.sqlx/query-236028886d13526daa184f9e6d0a4b2ae43fbaf7f0bbbc8246c5e2d73b6f6aee.json
similarity index 50%
rename from backend/.sqlx/query-9530b234b2ac8f7360552be5e6bb25270e6b4185ce5c7f3c6edd9fbaabf77544.json
rename to backend/.sqlx/query-236028886d13526daa184f9e6d0a4b2ae43fbaf7f0bbbc8246c5e2d73b6f6aee.json
index 25a898e2f1..6a43b44e46 100644
--- a/backend/.sqlx/query-9530b234b2ac8f7360552be5e6bb25270e6b4185ce5c7f3c6edd9fbaabf77544.json
+++ b/backend/.sqlx/query-236028886d13526daa184f9e6d0a4b2ae43fbaf7f0bbbc8246c5e2d73b6f6aee.json
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT DISTINCT email FROM usr WHERE added_via->>'source' = 'instance_group' AND added_via->>'group' = $1",
+ "query": "SELECT instance_role FROM instance_group WHERE name = $1 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "email",
+ "name": "instance_role",
"type_info": "Varchar"
}
],
@@ -15,8 +15,8 @@
]
},
"nullable": [
- false
+ true
]
},
- "hash": "9530b234b2ac8f7360552be5e6bb25270e6b4185ce5c7f3c6edd9fbaabf77544"
+ "hash": "236028886d13526daa184f9e6d0a4b2ae43fbaf7f0bbbc8246c5e2d73b6f6aee"
}
diff --git a/backend/.sqlx/query-255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68.json b/backend/.sqlx/query-255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68.json
new file mode 100644
index 0000000000..64b6d70542
--- /dev/null
+++ b/backend/.sqlx/query-255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM flow\n WHERE archived = false AND pg_column_size(value) >= $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "count!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int4"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68"
+}
diff --git a/backend/.sqlx/query-2d5272e17f5c96185c7f655a6580f1e849dca05d1418798f5ea11dd91c9477bc.json b/backend/.sqlx/query-2d5272e17f5c96185c7f655a6580f1e849dca05d1418798f5ea11dd91c9477bc.json
new file mode 100644
index 0000000000..d3799b5475
--- /dev/null
+++ b/backend/.sqlx/query-2d5272e17f5c96185c7f655a6580f1e849dca05d1418798f5ea11dd91c9477bc.json
@@ -0,0 +1,36 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT resource.path,\n COALESCE(left(pretty.value, $3), '') as \"value!\",\n COALESCE(length(pretty.value) > $3, false) as \"truncated!\"\n FROM resource, LATERAL (SELECT jsonb_pretty(resource.value) as value OFFSET 0) pretty\n WHERE workspace_id = $1 LIMIT $2",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "path",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "value!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "truncated!",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Int8",
+ "Int4"
+ ]
+ },
+ "nullable": [
+ false,
+ null,
+ null
+ ]
+ },
+ "hash": "2d5272e17f5c96185c7f655a6580f1e849dca05d1418798f5ea11dd91c9477bc"
+}
diff --git a/backend/.sqlx/query-c7c0b7f760f9616ec4a18a8916226b65990f5055c1bc25eedefd303be75f553f.json b/backend/.sqlx/query-374863f06094e404523e700df832243894e40a081fd2e7f95b466612aed054de.json
similarity index 65%
rename from backend/.sqlx/query-c7c0b7f760f9616ec4a18a8916226b65990f5055c1bc25eedefd303be75f553f.json
rename to backend/.sqlx/query-374863f06094e404523e700df832243894e40a081fd2e7f95b466612aed054de.json
index 3b175bb9b8..f8b19be3c5 100644
--- a/backend/.sqlx/query-c7c0b7f760f9616ec4a18a8916226b65990f5055c1bc25eedefd303be75f553f.json
+++ b/backend/.sqlx/query-374863f06094e404523e700df832243894e40a081fd2e7f95b466612aed054de.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT workspace_id FROM workspace_settings WHERE auto_invite->'instance_groups' IS NOT NULL AND auto_invite->'instance_groups' ? $1",
+ "query": "SELECT workspace_id FROM workspace_settings WHERE auto_invite->'instance_groups' ?| $1",
"describe": {
"columns": [
{
@@ -11,12 +11,12 @@
],
"parameters": {
"Left": [
- "Text"
+ "TextArray"
]
},
"nullable": [
false
]
},
- "hash": "c7c0b7f760f9616ec4a18a8916226b65990f5055c1bc25eedefd303be75f553f"
+ "hash": "374863f06094e404523e700df832243894e40a081fd2e7f95b466612aed054de"
}
diff --git a/backend/.sqlx/query-3b0eb0571f287eb64c84cf2c8303401af7b78d0fb6cbaa10ccb90769320c8c7a.json b/backend/.sqlx/query-3b0eb0571f287eb64c84cf2c8303401af7b78d0fb6cbaa10ccb90769320c8c7a.json
new file mode 100644
index 0000000000..ca79568bb0
--- /dev/null
+++ b/backend/.sqlx/query-3b0eb0571f287eb64c84cf2c8303401af7b78d0fb6cbaa10ccb90769320c8c7a.json
@@ -0,0 +1,26 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT\n COUNT(*) FILTER (WHERE slack_command_script IS NOT NULL)::BIGINT AS \"slack!\",\n COUNT(*) FILTER (WHERE teams_command_script IS NOT NULL)::BIGINT AS \"teams!\"\n FROM workspace_settings",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "slack!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "teams!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ null,
+ null
+ ]
+ },
+ "hash": "3b0eb0571f287eb64c84cf2c8303401af7b78d0fb6cbaa10ccb90769320c8c7a"
+}
diff --git a/backend/.sqlx/query-3bd4f38a1629a69ddda622b6b436198b47c2fe1a507358d49f05031e7beedab6.json b/backend/.sqlx/query-3bd4f38a1629a69ddda622b6b436198b47c2fe1a507358d49f05031e7beedab6.json
deleted file mode 100644
index 0c71ee8ad2..0000000000
--- a/backend/.sqlx/query-3bd4f38a1629a69ddda622b6b436198b47c2fe1a507358d49f05031e7beedab6.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT workspace_id,\n auto_invite->'instance_groups_roles' as instance_groups_roles,\n auto_invite->'instance_groups' as instance_groups_json\n FROM workspace_settings\n WHERE auto_invite->'instance_groups' ? $1\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "workspace_id",
- "type_info": "Varchar"
- },
- {
- "ordinal": 1,
- "name": "instance_groups_roles",
- "type_info": "Jsonb"
- },
- {
- "ordinal": 2,
- "name": "instance_groups_json",
- "type_info": "Jsonb"
- }
- ],
- "parameters": {
- "Left": [
- "Text"
- ]
- },
- "nullable": [
- false,
- null,
- null
- ]
- },
- "hash": "3bd4f38a1629a69ddda622b6b436198b47c2fe1a507358d49f05031e7beedab6"
-}
diff --git a/backend/.sqlx/query-5d160ba4958583f1ad42de846c544d8d8e81e1b54925a0c5f2cedc1817d99a1b.json b/backend/.sqlx/query-4348832b4b99021b19a752a0375f7728fb10035ff4a71b8e0242e1184d192a32.json
similarity index 58%
rename from backend/.sqlx/query-5d160ba4958583f1ad42de846c544d8d8e81e1b54925a0c5f2cedc1817d99a1b.json
rename to backend/.sqlx/query-4348832b4b99021b19a752a0375f7728fb10035ff4a71b8e0242e1184d192a32.json
index 8fcffe364a..b6342d9b36 100644
--- a/backend/.sqlx/query-5d160ba4958583f1ad42de846c544d8d8e81e1b54925a0c5f2cedc1817d99a1b.json
+++ b/backend/.sqlx/query-4348832b4b99021b19a752a0375f7728fb10035ff4a71b8e0242e1184d192a32.json
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT igroup FROM email_to_igroup WHERE email = $1",
+ "query": "SELECT name FROM instance_group WHERE name = $1 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "igroup",
+ "name": "name",
"type_info": "Varchar"
}
],
@@ -18,5 +18,5 @@
false
]
},
- "hash": "5d160ba4958583f1ad42de846c544d8d8e81e1b54925a0c5f2cedc1817d99a1b"
+ "hash": "4348832b4b99021b19a752a0375f7728fb10035ff4a71b8e0242e1184d192a32"
}
diff --git a/backend/.sqlx/query-43a689277803e5e2204e10263a5749675652c23a231fce65257b053b6faad231.json b/backend/.sqlx/query-43a689277803e5e2204e10263a5749675652c23a231fce65257b053b6faad231.json
new file mode 100644
index 0000000000..86af8f2b7b
--- /dev/null
+++ b/backend/.sqlx/query-43a689277803e5e2204e10263a5749675652c23a231fce65257b053b6faad231.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT name FROM instance_group WHERE name = ANY($1) ORDER BY name FOR UPDATE",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "name",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "TextArray"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "43a689277803e5e2204e10263a5749675652c23a231fce65257b053b6faad231"
+}
diff --git a/backend/.sqlx/query-472f224dc9e17d2c50cb9db13c34cc1fb9adb6d0ea36cf223541adb7cac17bdd.json b/backend/.sqlx/query-472f224dc9e17d2c50cb9db13c34cc1fb9adb6d0ea36cf223541adb7cac17bdd.json
deleted file mode 100644
index b60ecae184..0000000000
--- a/backend/.sqlx/query-472f224dc9e17d2c50cb9db13c34cc1fb9adb6d0ea36cf223541adb7cac17bdd.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT workspace_id, username, email\n FROM usr\n WHERE email = $1\n AND added_via->>'source' = 'instance_group'\n AND added_via->>'group' = $2\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "workspace_id",
- "type_info": "Varchar"
- },
- {
- "ordinal": 1,
- "name": "username",
- "type_info": "Varchar"
- },
- {
- "ordinal": 2,
- "name": "email",
- "type_info": "Varchar"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "Text"
- ]
- },
- "nullable": [
- false,
- false,
- false
- ]
- },
- "hash": "472f224dc9e17d2c50cb9db13c34cc1fb9adb6d0ea36cf223541adb7cac17bdd"
-}
diff --git a/backend/.sqlx/query-49bf26ae4b7e3421507f9e7e42c59ad7e0f481a9550e0f70e1145d5e541bd6e5.json b/backend/.sqlx/query-49bf26ae4b7e3421507f9e7e42c59ad7e0f481a9550e0f70e1145d5e541bd6e5.json
new file mode 100644
index 0000000000..005ac90b79
--- /dev/null
+++ b/backend/.sqlx/query-49bf26ae4b7e3421507f9e7e42c59ad7e0f481a9550e0f70e1145d5e541bd6e5.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT auto_invite->'instance_groups' as \"groups: serde_json::Value\",\n auto_invite->'instance_groups_roles' as \"roles: serde_json::Value\"\n FROM workspace_settings WHERE workspace_id = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "groups: serde_json::Value",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 1,
+ "name": "roles: serde_json::Value",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null,
+ null
+ ]
+ },
+ "hash": "49bf26ae4b7e3421507f9e7e42c59ad7e0f481a9550e0f70e1145d5e541bd6e5"
+}
diff --git a/backend/.sqlx/query-5344f222417c28efd4f724cbd83382fc69a223dfbb91ab40df895ab60d0f6228.json b/backend/.sqlx/query-5344f222417c28efd4f724cbd83382fc69a223dfbb91ab40df895ab60d0f6228.json
new file mode 100644
index 0000000000..08c08bbf75
--- /dev/null
+++ b/backend/.sqlx/query-5344f222417c28efd4f724cbd83382fc69a223dfbb91ab40df895ab60d0f6228.json
@@ -0,0 +1,26 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT trigger_kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM capture WHERE created_at > now() - interval '30 days' GROUP BY trigger_kind",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "kind!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "count!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ null,
+ null
+ ]
+ },
+ "hash": "5344f222417c28efd4f724cbd83382fc69a223dfbb91ab40df895ab60d0f6228"
+}
diff --git a/backend/.sqlx/query-66e2f8468ba64f22b7a7caa18639d7c833ac2ec573bd89d878b5c8b1afc74d3a.json b/backend/.sqlx/query-66e2f8468ba64f22b7a7caa18639d7c833ac2ec573bd89d878b5c8b1afc74d3a.json
deleted file mode 100644
index 15681aab51..0000000000
--- a/backend/.sqlx/query-66e2f8468ba64f22b7a7caa18639d7c833ac2ec573bd89d878b5c8b1afc74d3a.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT\n workspace_id,\n auto_invite->'instance_groups_roles' as instance_groups_roles,\n auto_invite->'instance_groups' as instance_groups_json\n FROM workspace_settings\n WHERE\n auto_invite->'instance_groups' IS NOT NULL\n AND auto_invite->'instance_groups' ? $1\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "workspace_id",
- "type_info": "Varchar"
- },
- {
- "ordinal": 1,
- "name": "instance_groups_roles",
- "type_info": "Jsonb"
- },
- {
- "ordinal": 2,
- "name": "instance_groups_json",
- "type_info": "Jsonb"
- }
- ],
- "parameters": {
- "Left": [
- "Text"
- ]
- },
- "nullable": [
- false,
- null,
- null
- ]
- },
- "hash": "66e2f8468ba64f22b7a7caa18639d7c833ac2ec573bd89d878b5c8b1afc74d3a"
-}
diff --git a/backend/.sqlx/query-6c6b4bd4bd19878fce25d3a8a5ee02686b358b616c318e4bfca084d96b38f1c4.json b/backend/.sqlx/query-6c6b4bd4bd19878fce25d3a8a5ee02686b358b616c318e4bfca084d96b38f1c4.json
new file mode 100644
index 0000000000..6576d06ffa
--- /dev/null
+++ b/backend/.sqlx/query-6c6b4bd4bd19878fce25d3a8a5ee02686b358b616c318e4bfca084d96b38f1c4.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT config AS \"config!\" FROM config WHERE name LIKE 'worker__%' AND config IS NOT NULL",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "config!",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ true
+ ]
+ },
+ "hash": "6c6b4bd4bd19878fce25d3a8a5ee02686b358b616c318e4bfca084d96b38f1c4"
+}
diff --git a/backend/.sqlx/query-6fda4517a72b25b0eab47bc69127ee45467d4d45239fb556534429b03442b27b.json b/backend/.sqlx/query-6fda4517a72b25b0eab47bc69127ee45467d4d45239fb556534429b03442b27b.json
new file mode 100644
index 0000000000..17641bc8f7
--- /dev/null
+++ b/backend/.sqlx/query-6fda4517a72b25b0eab47bc69127ee45467d4d45239fb556534429b03442b27b.json
@@ -0,0 +1,46 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT username, email, is_admin, operator,\n added_via->>'group' as granting_group\n FROM usr\n WHERE workspace_id = $1 AND added_via->>'source' = 'instance_group'\n ORDER BY email",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "username",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "email",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "is_admin",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 3,
+ "name": "operator",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 4,
+ "name": "granting_group",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ null
+ ]
+ },
+ "hash": "6fda4517a72b25b0eab47bc69127ee45467d4d45239fb556534429b03442b27b"
+}
diff --git a/backend/.sqlx/query-772dc28e57666282d8993268843d3a87e8899b968827251745f998e4dc25863a.json b/backend/.sqlx/query-772dc28e57666282d8993268843d3a87e8899b968827251745f998e4dc25863a.json
new file mode 100644
index 0000000000..d5659d725b
--- /dev/null
+++ b/backend/.sqlx/query-772dc28e57666282d8993268843d3a87e8899b968827251745f998e4dc25863a.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO feature_usage (feature, kind, key, value)\n SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[], $4::bigint[])\n ON CONFLICT (feature, kind, key, entity_id, day)\n DO UPDATE SET value = feature_usage.value + EXCLUDED.value, updated_at = now()",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "TextArray",
+ "TextArray",
+ "TextArray",
+ "Int8Array"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "772dc28e57666282d8993268843d3a87e8899b968827251745f998e4dc25863a"
+}
diff --git a/backend/.sqlx/query-2ee6d24b95cdda151585dcff19f8e7c931785fc21f7bbe9c3a82671943ced0ea.json b/backend/.sqlx/query-7947ffe31b8e6f4a38fba9d9caf434fd409ea2806a7c720c2b1d76b2e7db6c32.json
similarity index 65%
rename from backend/.sqlx/query-2ee6d24b95cdda151585dcff19f8e7c931785fc21f7bbe9c3a82671943ced0ea.json
rename to backend/.sqlx/query-7947ffe31b8e6f4a38fba9d9caf434fd409ea2806a7c720c2b1d76b2e7db6c32.json
index 6acb31666c..e69a61e65d 100644
--- a/backend/.sqlx/query-2ee6d24b95cdda151585dcff19f8e7c931785fc21f7bbe9c3a82671943ced0ea.json
+++ b/backend/.sqlx/query-7947ffe31b8e6f4a38fba9d9caf434fd409ea2806a7c720c2b1d76b2e7db6c32.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3",
+ "query": "UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3 AND enabled = true",
"describe": {
"columns": [],
"parameters": {
@@ -12,5 +12,5 @@
},
"nullable": []
},
- "hash": "2ee6d24b95cdda151585dcff19f8e7c931785fc21f7bbe9c3a82671943ced0ea"
+ "hash": "7947ffe31b8e6f4a38fba9d9caf434fd409ea2806a7c720c2b1d76b2e7db6c32"
}
diff --git a/backend/.sqlx/query-7b3eadb62ddd07e5e12eb8b5150ddce33008b61bbdab4006194ca7ef5b754802.json b/backend/.sqlx/query-7b3eadb62ddd07e5e12eb8b5150ddce33008b61bbdab4006194ca7ef5b754802.json
new file mode 100644
index 0000000000..6ca02417f3
--- /dev/null
+++ b/backend/.sqlx/query-7b3eadb62ddd07e5e12eb8b5150ddce33008b61bbdab4006194ca7ef5b754802.json
@@ -0,0 +1,26 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT\n (SELECT COUNT(*) FROM script\n WHERE dedicated_worker = true AND archived = false AND deleted = false)::BIGINT AS \"scripts!\",\n (SELECT COUNT(*) FROM flow WHERE dedicated_worker = true AND archived = false)::BIGINT AS \"flows!\"",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "scripts!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "flows!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ null,
+ null
+ ]
+ },
+ "hash": "7b3eadb62ddd07e5e12eb8b5150ddce33008b61bbdab4006194ca7ef5b754802"
+}
diff --git a/backend/.sqlx/query-f582cac90b4b7d732956b74eebc51323ef8acd3f627e7517451fcc72998d22bc.json b/backend/.sqlx/query-815c5e8fd91dc119a803f4f1f56b8016bdaabfa2ff61a1abe8f73f5a7336ab61.json
similarity index 64%
rename from backend/.sqlx/query-f582cac90b4b7d732956b74eebc51323ef8acd3f627e7517451fcc72998d22bc.json
rename to backend/.sqlx/query-815c5e8fd91dc119a803f4f1f56b8016bdaabfa2ff61a1abe8f73f5a7336ab61.json
index 5bfc4710c8..fc25b16733 100644
--- a/backend/.sqlx/query-f582cac90b4b7d732956b74eebc51323ef8acd3f627e7517451fcc72998d22bc.json
+++ b/backend/.sqlx/query-815c5e8fd91dc119a803f4f1f56b8016bdaabfa2ff61a1abe8f73f5a7336ab61.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "UPDATE usr SET added_via = $1 WHERE workspace_id = $2 AND email = $3",
+ "query": "UPDATE usr SET added_via = $1 WHERE workspace_id = $2 AND email = $3 AND added_via->>'source' = 'instance_group'",
"describe": {
"columns": [],
"parameters": {
@@ -12,5 +12,5 @@
},
"nullable": []
},
- "hash": "f582cac90b4b7d732956b74eebc51323ef8acd3f627e7517451fcc72998d22bc"
+ "hash": "815c5e8fd91dc119a803f4f1f56b8016bdaabfa2ff61a1abe8f73f5a7336ab61"
}
diff --git a/backend/.sqlx/query-83ec97f6aad154e0e06ee05a3647dab8f89b1b2d7a569c7eac8c4169e37b9f8b.json b/backend/.sqlx/query-83ec97f6aad154e0e06ee05a3647dab8f89b1b2d7a569c7eac8c4169e37b9f8b.json
deleted file mode 100644
index 4e0bc23b69..0000000000
--- a/backend/.sqlx/query-83ec97f6aad154e0e06ee05a3647dab8f89b1b2d7a569c7eac8c4169e37b9f8b.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT value FROM global_settings WHERE name = 'smtp_settings'",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "value",
- "type_info": "Jsonb"
- }
- ],
- "parameters": {
- "Left": []
- },
- "nullable": [
- false
- ]
- },
- "hash": "83ec97f6aad154e0e06ee05a3647dab8f89b1b2d7a569c7eac8c4169e37b9f8b"
-}
diff --git a/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json b/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json
new file mode 100644
index 0000000000..557466e8b7
--- /dev/null
+++ b/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json
@@ -0,0 +1,35 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT j.runnable_path,\n CASE\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id\n WHERE j.workspace_id = $2",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "runnable_path",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "scope_kind",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "launched_by_app",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Text"
+ ]
+ },
+ "nullable": [
+ true,
+ null,
+ null
+ ]
+ },
+ "hash": "84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9"
+}
diff --git a/backend/.sqlx/query-75e740531bf794a8568350348253612cfbdecb9fb9cf768f431d4dbd4cd56cfb.json b/backend/.sqlx/query-87873ad46b94e26f840d1710146e90c639beb2a5389432e64ecd94adbf154516.json
similarity index 51%
rename from backend/.sqlx/query-75e740531bf794a8568350348253612cfbdecb9fb9cf768f431d4dbd4cd56cfb.json
rename to backend/.sqlx/query-87873ad46b94e26f840d1710146e90c639beb2a5389432e64ecd94adbf154516.json
index 3445985466..c2e40c655e 100644
--- a/backend/.sqlx/query-75e740531bf794a8568350348253612cfbdecb9fb9cf768f431d4dbd4cd56cfb.json
+++ b/backend/.sqlx/query-87873ad46b94e26f840d1710146e90c639beb2a5389432e64ecd94adbf154516.json
@@ -1,22 +1,23 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT auto_invite->'instance_groups' FROM workspace_settings WHERE workspace_id = $1",
+ "query": "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "?column?",
+ "name": "policy",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
+ "Text",
"Text"
]
},
"nullable": [
- null
+ false
]
},
- "hash": "75e740531bf794a8568350348253612cfbdecb9fb9cf768f431d4dbd4cd56cfb"
+ "hash": "87873ad46b94e26f840d1710146e90c639beb2a5389432e64ecd94adbf154516"
}
diff --git a/backend/.sqlx/query-928767710fb8b7dc0b1edc897d8ce9b6b59ae2f63e684d3db6eecbcffd767711.json b/backend/.sqlx/query-928767710fb8b7dc0b1edc897d8ce9b6b59ae2f63e684d3db6eecbcffd767711.json
new file mode 100644
index 0000000000..36e067a79a
--- /dev/null
+++ b/backend/.sqlx/query-928767710fb8b7dc0b1edc897d8ce9b6b59ae2f63e684d3db6eecbcffd767711.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO trigger_history\n (workspace_id, trigger_kind, path, operation, source, username, changes)\n SELECT $1, $2, p, $3, $4, $5, $6 FROM unnest($7::text[]) p",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Varchar",
+ "Varchar",
+ "Varchar",
+ "Varchar",
+ "Jsonb",
+ "TextArray"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "928767710fb8b7dc0b1edc897d8ce9b6b59ae2f63e684d3db6eecbcffd767711"
+}
diff --git a/backend/.sqlx/query-9d9fbcb598c582a29d65be87a1e35baa410f93be3cac4b8dfd33ddbef446d3fe.json b/backend/.sqlx/query-9d9fbcb598c582a29d65be87a1e35baa410f93be3cac4b8dfd33ddbef446d3fe.json
deleted file mode 100644
index 2c95e06391..0000000000
--- a/backend/.sqlx/query-9d9fbcb598c582a29d65be87a1e35baa410f93be3cac4b8dfd33ddbef446d3fe.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL\n RETURNING jobs_executed",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "jobs_executed",
- "type_info": "Int4"
- }
- ],
- "parameters": {
- "Left": [
- "Varchar",
- "Varchar",
- "Varchar",
- "TextArray",
- "Varchar",
- "Varchar",
- "TextArray",
- "Varchar",
- "Int8",
- "Int8",
- "Text",
- "Bool"
- ]
- },
- "nullable": [
- false
- ]
- },
- "hash": "9d9fbcb598c582a29d65be87a1e35baa410f93be3cac4b8dfd33ddbef446d3fe"
-}
diff --git a/backend/.sqlx/query-a2f047f9ca4b8a47c985fa092ba0d2dc54f7169af1c940b84345c477865ae82c.json b/backend/.sqlx/query-a2f047f9ca4b8a47c985fa092ba0d2dc54f7169af1c940b84345c477865ae82c.json
new file mode 100644
index 0000000000..800d6c3305
--- /dev/null
+++ b/backend/.sqlx/query-a2f047f9ca4b8a47c985fa092ba0d2dc54f7169af1c940b84345c477865ae82c.json
@@ -0,0 +1,26 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT trigger_kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM capture_config GROUP BY trigger_kind",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "kind!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "count!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ null,
+ null
+ ]
+ },
+ "hash": "a2f047f9ca4b8a47c985fa092ba0d2dc54f7169af1c940b84345c477865ae82c"
+}
diff --git a/backend/.sqlx/query-ae1973cf7dda23c1583521c7edd1a7e3beb695edaa00adf875cfdbb81ebe96dc.json b/backend/.sqlx/query-ae1973cf7dda23c1583521c7edd1a7e3beb695edaa00adf875cfdbb81ebe96dc.json
new file mode 100644
index 0000000000..e1a5206335
--- /dev/null
+++ b/backend/.sqlx/query-ae1973cf7dda23c1583521c7edd1a7e3beb695edaa00adf875cfdbb81ebe96dc.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE workspace_settings SET\n auto_invite = jsonb_set(\n jsonb_set(\n COALESCE(auto_invite, '{}'::jsonb),\n '{instance_groups}',\n (SELECT COALESCE(jsonb_agg(\n CASE WHEN elem #>> '{}' = $1 THEN to_jsonb($2::text) ELSE elem END), '[]'::jsonb)\n FROM jsonb_array_elements(COALESCE(auto_invite->'instance_groups', '[]'::jsonb)) elem)\n ),\n '{instance_groups_roles}',\n CASE WHEN COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb) ? $1\n THEN (COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb) - $1)\n || jsonb_build_object($2::text, auto_invite->'instance_groups_roles'->$1)\n ELSE COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb)\n END\n )\n WHERE auto_invite->'instance_groups' ? $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "ae1973cf7dda23c1583521c7edd1a7e3beb695edaa00adf875cfdbb81ebe96dc"
+}
diff --git a/backend/.sqlx/query-bbc2638aae4fb3556c8d876e4efd402c7b7c93ff9fd89364afd41470a92a5e2d.json b/backend/.sqlx/query-bbc2638aae4fb3556c8d876e4efd402c7b7c93ff9fd89364afd41470a92a5e2d.json
new file mode 100644
index 0000000000..e59183f51a
--- /dev/null
+++ b/backend/.sqlx/query-bbc2638aae4fb3556c8d876e4efd402c7b7c93ff9fd89364afd41470a92a5e2d.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT name FROM instance_group WHERE name <> ALL($1)",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "name",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "TextArray"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "bbc2638aae4fb3556c8d876e4efd402c7b7c93ff9fd89364afd41470a92a5e2d"
+}
diff --git a/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json b/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json
new file mode 100644
index 0000000000..7f6fe7a81c
--- /dev/null
+++ b/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json
@@ -0,0 +1,33 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL\n RETURNING jobs_executed",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "jobs_executed",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Varchar",
+ "Text",
+ "TextArray",
+ "Varchar",
+ "Varchar",
+ "TextArray",
+ "Varchar",
+ "Int8",
+ "Int8",
+ "Text",
+ "Bool"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc"
+}
diff --git a/backend/.sqlx/query-d069ad741996e3ea992bcfb33e290ba5b87be1c047c189c9b8eb13da97e2085d.json b/backend/.sqlx/query-d069ad741996e3ea992bcfb33e290ba5b87be1c047c189c9b8eb13da97e2085d.json
new file mode 100644
index 0000000000..7e667694b9
--- /dev/null
+++ b/backend/.sqlx/query-d069ad741996e3ea992bcfb33e290ba5b87be1c047c189c9b8eb13da97e2085d.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT pg_advisory_xact_lock(hashtext('reconcile_workspace_instance_groups'), hashtext($1))",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "pg_advisory_xact_lock",
+ "type_info": "Void"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "d069ad741996e3ea992bcfb33e290ba5b87be1c047c189c9b8eb13da97e2085d"
+}
diff --git a/backend/.sqlx/query-d1f701e81fc98933802356f292455da604367ef35893c2bb095bcd637567b114.json b/backend/.sqlx/query-d1f701e81fc98933802356f292455da604367ef35893c2bb095bcd637567b114.json
new file mode 100644
index 0000000000..064e7c38aa
--- /dev/null
+++ b/backend/.sqlx/query-d1f701e81fc98933802356f292455da604367ef35893c2bb095bcd637567b114.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT email, igroup FROM email_to_igroup WHERE igroup = ANY($1)",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "email",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "igroup",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "TextArray"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "d1f701e81fc98933802356f292455da604367ef35893c2bb095bcd637567b114"
+}
diff --git a/backend/.sqlx/query-dab323eda1fcaff0435e98d77e544ed5d63dd6023b1dab77d5f188b299f03b9d.json b/backend/.sqlx/query-dab323eda1fcaff0435e98d77e544ed5d63dd6023b1dab77d5f188b299f03b9d.json
deleted file mode 100644
index ee4c5ff1af..0000000000
--- a/backend/.sqlx/query-dab323eda1fcaff0435e98d77e544ed5d63dd6023b1dab77d5f188b299f03b9d.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "UPDATE workspace_settings SET\n auto_invite = jsonb_set(\n jsonb_set(\n COALESCE(auto_invite, '{}'::jsonb),\n '{instance_groups}',\n (SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb) FROM jsonb_array_elements(COALESCE(auto_invite->'instance_groups', '[]'::jsonb)) elem WHERE elem #>> '{}' != $1)\n ),\n '{instance_groups_roles}',\n COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb) - $1\n )\n WHERE auto_invite->'instance_groups' IS NOT NULL AND auto_invite->'instance_groups' ? $1",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Text"
- ]
- },
- "nullable": []
- },
- "hash": "dab323eda1fcaff0435e98d77e544ed5d63dd6023b1dab77d5f188b299f03b9d"
-}
diff --git a/backend/.sqlx/query-db9b48f91a2387e08a2eaa5bda344edf83bfcb8c8c330d524ed4eaafedbdbc0e.json b/backend/.sqlx/query-db9b48f91a2387e08a2eaa5bda344edf83bfcb8c8c330d524ed4eaafedbdbc0e.json
new file mode 100644
index 0000000000..05c496b552
--- /dev/null
+++ b/backend/.sqlx/query-db9b48f91a2387e08a2eaa5bda344edf83bfcb8c8c330d524ed4eaafedbdbc0e.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO global_settings (name, value) VALUES ($1, $2)\n ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value\n WHERE jsonb_typeof(global_settings.value) <> 'string'\n RETURNING value",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "value",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Jsonb"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "db9b48f91a2387e08a2eaa5bda344edf83bfcb8c8c330d524ed4eaafedbdbc0e"
+}
diff --git a/backend/.sqlx/query-e24252d48a1fcca73f20d62f37c9d7dc2071580be6d604979c95b3374cd4ad77.json b/backend/.sqlx/query-e24252d48a1fcca73f20d62f37c9d7dc2071580be6d604979c95b3374cd4ad77.json
new file mode 100644
index 0000000000..d52ba8fb85
--- /dev/null
+++ b/backend/.sqlx/query-e24252d48a1fcca73f20d62f37c9d7dc2071580be6d604979c95b3374cd4ad77.json
@@ -0,0 +1,173 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n WITH scanned AS (\n SELECT a.* FROM (\n SELECT value FROM flow\n WHERE archived = false AND pg_column_size(value) < $1\n LIMIT $2\n ) f,\n LATERAL (\n SELECT\n bool_or(m->'value'->>'type' = 'forloopflow') AS forloop,\n bool_or(m->'value'->>'type' = 'whileloopflow') AS whileloop,\n bool_or(m->'value'->>'type' = 'branchall') AS branchall,\n bool_or(m->'value'->>'type' = 'branchall'\n AND m->'value'->>'parallel' = 'false') AS branchall_seq,\n bool_or(m->'value'->>'type' = 'branchone') AS branchone,\n bool_or(m->'value'->>'type' = 'aiagent') AS aiagent,\n bool_or(m->'value'->>'type' = 'flow') AS subflow,\n bool_or(m->'value'->>'type' = 'identity') AS identity,\n bool_or(m->'value'->>'is_trigger' = 'true') AS trigger_step,\n bool_or(m->'value'->>'squash' = 'true') AS squash,\n bool_or(m->'value'->>'type' IN ('forloopflow', 'whileloopflow')\n AND m->'value'->>'parallel' = 'true') AS parallel_loop,\n bool_or(m->'value'->>'type' IN ('forloopflow', 'whileloopflow')\n AND m->'value'->>'skip_failures' = 'false') AS keep_failures,\n bool_or(m->'value' ? 'parallelism') AS parallelism,\n bool_or(m ? 'sleep') AS sleep,\n bool_or(m ? 'cache_ttl') AS cache,\n bool_or(m->'mock'->>'enabled' = 'true') AS mock,\n bool_or(m ? 'suspend') AS suspend,\n bool_or(m ? 'retry') AS retry,\n bool_or(m ? 'timeout') AS timeout,\n bool_or(m ? 'priority') AS priority,\n bool_or(m ? 'debouncing') AS debounce,\n bool_or(m ? 'delete_after_secs') AS lifetime,\n bool_or(m->>'continue_on_error' = 'true') AS continue_on_error,\n bool_or(m ? 'stop_after_if' OR m ? 'stop_after_all_iters_if') AS early_stop,\n bool_or(m ? 'skip_if') AS skip\n FROM jsonb_path_query(f.value, '$.**.modules[*]') m\n ) a\n )\n SELECT\n COUNT(*)::BIGINT AS \"flows_scanned!\",\n COUNT(*) FILTER (WHERE forloop)::BIGINT AS \"forloopflow!\",\n COUNT(*) FILTER (WHERE whileloop)::BIGINT AS \"whileloopflow!\",\n COUNT(*) FILTER (WHERE branchall)::BIGINT AS \"branchall!\",\n COUNT(*) FILTER (WHERE branchall_seq)::BIGINT AS \"branchall_sequential!\",\n COUNT(*) FILTER (WHERE branchone)::BIGINT AS \"branchone!\",\n COUNT(*) FILTER (WHERE aiagent)::BIGINT AS \"aiagent!\",\n COUNT(*) FILTER (WHERE subflow)::BIGINT AS \"subflow!\",\n COUNT(*) FILTER (WHERE identity)::BIGINT AS \"identity!\",\n COUNT(*) FILTER (WHERE trigger_step)::BIGINT AS \"trigger_step!\",\n COUNT(*) FILTER (WHERE squash)::BIGINT AS \"squash!\",\n COUNT(*) FILTER (WHERE parallel_loop)::BIGINT AS \"parallel_loop!\",\n COUNT(*) FILTER (WHERE keep_failures)::BIGINT AS \"keep_failures!\",\n COUNT(*) FILTER (WHERE parallelism)::BIGINT AS \"parallelism!\",\n COUNT(*) FILTER (WHERE sleep)::BIGINT AS \"sleep!\",\n COUNT(*) FILTER (WHERE cache)::BIGINT AS \"cache!\",\n COUNT(*) FILTER (WHERE mock)::BIGINT AS \"mock!\",\n COUNT(*) FILTER (WHERE suspend)::BIGINT AS \"suspend!\",\n COUNT(*) FILTER (WHERE retry)::BIGINT AS \"retry!\",\n COUNT(*) FILTER (WHERE timeout)::BIGINT AS \"timeout!\",\n COUNT(*) FILTER (WHERE priority)::BIGINT AS \"priority!\",\n COUNT(*) FILTER (WHERE debounce)::BIGINT AS \"debounce!\",\n COUNT(*) FILTER (WHERE lifetime)::BIGINT AS \"lifetime!\",\n COUNT(*) FILTER (WHERE continue_on_error)::BIGINT AS \"continue_on_error!\",\n COUNT(*) FILTER (WHERE early_stop)::BIGINT AS \"early_stop!\",\n COUNT(*) FILTER (WHERE skip)::BIGINT AS \"skip!\"\n FROM scanned\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "flows_scanned!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "forloopflow!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "whileloopflow!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 3,
+ "name": "branchall!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 4,
+ "name": "branchall_sequential!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 5,
+ "name": "branchone!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 6,
+ "name": "aiagent!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 7,
+ "name": "subflow!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 8,
+ "name": "identity!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 9,
+ "name": "trigger_step!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 10,
+ "name": "squash!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 11,
+ "name": "parallel_loop!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 12,
+ "name": "keep_failures!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 13,
+ "name": "parallelism!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 14,
+ "name": "sleep!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 15,
+ "name": "cache!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 16,
+ "name": "mock!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 17,
+ "name": "suspend!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 18,
+ "name": "retry!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 19,
+ "name": "timeout!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 20,
+ "name": "priority!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 21,
+ "name": "debounce!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 22,
+ "name": "lifetime!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 23,
+ "name": "continue_on_error!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 24,
+ "name": "early_stop!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 25,
+ "name": "skip!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int4",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null
+ ]
+ },
+ "hash": "e24252d48a1fcca73f20d62f37c9d7dc2071580be6d604979c95b3374cd4ad77"
+}
diff --git a/backend/.sqlx/query-e242c733ca0accb5287e80c97abe3bbae0638612bd111b6b6196dd2808d4b6d0.json b/backend/.sqlx/query-e242c733ca0accb5287e80c97abe3bbae0638612bd111b6b6196dd2808d4b6d0.json
new file mode 100644
index 0000000000..d28a19f599
--- /dev/null
+++ b/backend/.sqlx/query-e242c733ca0accb5287e80c97abe3bbae0638612bd111b6b6196dd2808d4b6d0.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE workspace_settings SET\n auto_invite = jsonb_set(\n jsonb_set(\n COALESCE(auto_invite, '{}'::jsonb),\n '{instance_groups}',\n (SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)\n FROM jsonb_array_elements(COALESCE(auto_invite->'instance_groups', '[]'::jsonb)) elem\n WHERE elem #>> '{}' <> ALL($1))\n ),\n '{instance_groups_roles}',\n CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'\n THEN (auto_invite->'instance_groups_roles') - $1::text[]\n ELSE '{}'::jsonb\n END\n )\n WHERE auto_invite->'instance_groups' ?| $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "TextArray"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "e242c733ca0accb5287e80c97abe3bbae0638612bd111b6b6196dd2808d4b6d0"
+}
diff --git a/backend/.sqlx/query-565db14b889f69dfbda5db400a33223fd20548fb106d2a5e5669c0e23ecaa0eb.json b/backend/.sqlx/query-e4e621724d830b318c06734683c8a48b0b692f7e390f2e95162d39ff3f6aeba7.json
similarity index 64%
rename from backend/.sqlx/query-565db14b889f69dfbda5db400a33223fd20548fb106d2a5e5669c0e23ecaa0eb.json
rename to backend/.sqlx/query-e4e621724d830b318c06734683c8a48b0b692f7e390f2e95162d39ff3f6aeba7.json
index af526daf8f..fee76e4be4 100644
--- a/backend/.sqlx/query-565db14b889f69dfbda5db400a33223fd20548fb106d2a5e5669c0e23ecaa0eb.json
+++ b/backend/.sqlx/query-e4e621724d830b318c06734683c8a48b0b692f7e390f2e95162d39ff3f6aeba7.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT name FROM instance_group WHERE name = $1",
+ "query": "SELECT name FROM instance_group WHERE id = $1 FOR UPDATE",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
false
]
},
- "hash": "565db14b889f69dfbda5db400a33223fd20548fb106d2a5e5669c0e23ecaa0eb"
+ "hash": "e4e621724d830b318c06734683c8a48b0b692f7e390f2e95162d39ff3f6aeba7"
}
diff --git a/backend/.sqlx/query-e52a80386b132e53a956458e2eb77d29bd6da4fc8511ed44f21d49f4c0965d36.json b/backend/.sqlx/query-e52a80386b132e53a956458e2eb77d29bd6da4fc8511ed44f21d49f4c0965d36.json
new file mode 100644
index 0000000000..26a5b9f932
--- /dev/null
+++ b/backend/.sqlx/query-e52a80386b132e53a956458e2eb77d29bd6da4fc8511ed44f21d49f4c0965d36.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO trigger_history\n (workspace_id, trigger_kind, path, operation, source, username, changes)\n VALUES ($1, $2, $3, $4, $5, $6, $7)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Varchar",
+ "Varchar",
+ "Varchar",
+ "Varchar",
+ "Varchar",
+ "Jsonb"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "e52a80386b132e53a956458e2eb77d29bd6da4fc8511ed44f21d49f4c0965d36"
+}
diff --git a/backend/.sqlx/query-e63e275a158040659c41ec8d1ef9107558b0003f495ba3f5fba63b77616d12c8.json b/backend/.sqlx/query-e63e275a158040659c41ec8d1ef9107558b0003f495ba3f5fba63b77616d12c8.json
new file mode 100644
index 0000000000..d5a9d425da
--- /dev/null
+++ b/backend/.sqlx/query-e63e275a158040659c41ec8d1ef9107558b0003f495ba3f5fba63b77616d12c8.json
@@ -0,0 +1,26 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM script WHERE archived = false AND deleted = false GROUP BY kind",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "kind!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "count!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ null,
+ null
+ ]
+ },
+ "hash": "e63e275a158040659c41ec8d1ef9107558b0003f495ba3f5fba63b77616d12c8"
+}
diff --git a/backend/.sqlx/query-ee182378d9a760c3593b483703ec1682488ca9e6e402ec41abaa6ae3ca263854.json b/backend/.sqlx/query-ee182378d9a760c3593b483703ec1682488ca9e6e402ec41abaa6ae3ca263854.json
deleted file mode 100644
index ec170d17c8..0000000000
--- a/backend/.sqlx/query-ee182378d9a760c3593b483703ec1682488ca9e6e402ec41abaa6ae3ca263854.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT email_to_igroup.email\n FROM email_to_igroup\n INNER JOIN instance_group ON instance_group.name = email_to_igroup.igroup\n WHERE instance_group.name = $1\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "email",
- "type_info": "Varchar"
- }
- ],
- "parameters": {
- "Left": [
- "Text"
- ]
- },
- "nullable": [
- false
- ]
- },
- "hash": "ee182378d9a760c3593b483703ec1682488ca9e6e402ec41abaa6ae3ca263854"
-}
diff --git a/backend/.sqlx/query-fcbbc3b697249c6ee0ca542ea42fadecddead568110dd531fbace439c2263e4f.json b/backend/.sqlx/query-fcbbc3b697249c6ee0ca542ea42fadecddead568110dd531fbace439c2263e4f.json
new file mode 100644
index 0000000000..b039aab41c
--- /dev/null
+++ b/backend/.sqlx/query-fcbbc3b697249c6ee0ca542ea42fadecddead568110dd531fbace439c2263e4f.json
@@ -0,0 +1,71 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, trigger_kind, path, operation, source, username, created_at, changes\n FROM trigger_history\n WHERE workspace_id = $1\n AND ($2::TEXT IS NULL OR trigger_kind = $2)\n AND ($3::TEXT IS NULL OR path = $3)\n AND ( $6\n OR path = ANY($7)\n OR EXISTS ( SELECT 1 FROM unnest($8::text[]) AS pfx\n WHERE path = pfx\n OR left(path, length(pfx) + 1) = pfx || '/' ) )\n ORDER BY id DESC\n LIMIT $4 OFFSET $5",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "trigger_kind",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "path",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "operation",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 4,
+ "name": "source",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 5,
+ "name": "username",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 6,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 7,
+ "name": "changes",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Text",
+ "Int8",
+ "Int8",
+ "Bool",
+ "TextArray",
+ "TextArray"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false,
+ true
+ ]
+ },
+ "hash": "fcbbc3b697249c6ee0ca542ea42fadecddead568110dd531fbace439c2263e4f"
+}
diff --git a/backend/.sqlx/query-fed842c14aa37998da2b3cfafc71f7364132ea1e40e687aa84c3d02399e3bfb5.json b/backend/.sqlx/query-fed842c14aa37998da2b3cfafc71f7364132ea1e40e687aa84c3d02399e3bfb5.json
deleted file mode 100644
index 78c0158757..0000000000
--- a/backend/.sqlx/query-fed842c14aa37998da2b3cfafc71f7364132ea1e40e687aa84c3d02399e3bfb5.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT path, value from resource WHERE workspace_id = $1 LIMIT $2",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "path",
- "type_info": "Varchar"
- },
- {
- "ordinal": 1,
- "name": "value",
- "type_info": "Jsonb"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "Int8"
- ]
- },
- "nullable": [
- false,
- true
- ]
- },
- "hash": "fed842c14aa37998da2b3cfafc71f7364132ea1e40e687aa84c3d02399e3bfb5"
-}
diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 3c965b2a9a..806966c2b1 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1339,7 +1339,7 @@ dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2 0.3.27",
- "h2 0.4.15",
+ "h2 0.4.16",
"http 0.2.12",
"http 1.5.0",
"http-body 0.4.6",
@@ -2302,9 +2302,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.4.2"
+version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
+checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -3958,7 +3958,7 @@ dependencies = [
"deno_tls",
"dyn-clone",
"error_reporter",
- "h2 0.4.15",
+ "h2 0.4.16",
"hickory-resolver",
"http 1.5.0",
"http-body-util",
@@ -5011,9 +5011,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
-version = "0.1.10"
+version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "fixedbitset"
@@ -5750,9 +5750,9 @@ dependencies = [
[[package]]
name = "h2"
-version = "0.4.15"
+version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
+checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -6170,7 +6170,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "h2 0.4.15",
+ "h2 0.4.16",
"http 1.5.0",
"http-body 1.1.0",
"httparse",
@@ -6378,9 +6378,9 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.2.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
@@ -6392,9 +6392,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.2.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
@@ -6405,9 +6405,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.2.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -6419,16 +6419,17 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.2.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
-version = "2.2.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
+ "displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
@@ -6439,15 +6440,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.2.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
-version = "2.2.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -7196,9 +7197,9 @@ dependencies = [
[[package]]
name = "libredox"
-version = "0.1.19"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa"
+checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
dependencies = [
"bitflags 2.13.1",
"libc",
@@ -7275,9 +7276,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "lock_api"
@@ -7684,9 +7685,9 @@ dependencies = [
[[package]]
name = "minicov"
-version = "0.3.8"
+version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
+checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0"
dependencies = [
"cc",
"walkdir",
@@ -9005,9 +9006,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
-version = "2.8.8"
+version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2"
+checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
dependencies = [
"memchr",
"ucd-trie",
@@ -9015,9 +9016,9 @@ dependencies = [
[[package]]
name = "pest_derive"
-version = "2.8.8"
+version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd"
+checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
dependencies = [
"pest",
"pest_generator",
@@ -9025,9 +9026,9 @@ dependencies = [
[[package]]
name = "pest_generator"
-version = "2.8.8"
+version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6"
+checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
dependencies = [
"pest",
"pest_meta",
@@ -9038,9 +9039,9 @@ dependencies = [
[[package]]
name = "pest_meta"
-version = "2.8.8"
+version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c"
+checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
dependencies = [
"pest",
]
@@ -9273,9 +9274,9 @@ dependencies = [
[[package]]
name = "pkg-config"
-version = "0.3.33"
+version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "plain"
@@ -9398,9 +9399,9 @@ dependencies = [
[[package]]
name = "potential_utf"
-version = "0.1.5"
+version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
@@ -9730,9 +9731,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
-version = "0.11.16"
+version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
+checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"aws-lc-rs",
"bytes",
@@ -10105,18 +10106,18 @@ dependencies = [
[[package]]
name = "ref-cast"
-version = "1.0.26"
+version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
+checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
-version = "1.0.26"
+version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
+checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
@@ -10194,7 +10195,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
- "h2 0.4.15",
+ "h2 0.4.16",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
@@ -10242,7 +10243,7 @@ dependencies = [
"encoding_rs",
"futures-core",
"futures-util",
- "h2 0.4.15",
+ "h2 0.4.16",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
@@ -10431,9 +10432,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
-version = "3.1.2"
+version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6898e24cd16342b59bfa8a53c2c04b9cf62fc8a2cfea57b9c038b09984bfc521"
+checksum = "54817231d63a35330d5c3a5782a4a166fbe3b914a9faab440d3dd8bd37971890"
dependencies = [
"darling 0.24.0",
"proc-macro2",
@@ -13115,9 +13116,9 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
@@ -13513,7 +13514,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"flate2",
- "h2 0.4.15",
+ "h2 0.4.16",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
@@ -13545,7 +13546,7 @@ dependencies = [
"axum 0.8.9",
"base64 0.22.1",
"bytes",
- "h2 0.4.15",
+ "h2 0.4.16",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
@@ -14252,9 +14253,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.24.0"
+version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
+checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -14664,7 +14665,7 @@ dependencies = [
[[package]]
name = "windmill"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-nats",
@@ -14749,7 +14750,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"async-stream",
"async-trait",
@@ -14782,7 +14783,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14795,7 +14796,7 @@ dependencies = [
[[package]]
name = "windmill-api"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"argon2",
@@ -14935,7 +14936,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14958,7 +14959,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14975,7 +14976,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15001,7 +15002,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15011,7 +15012,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15028,7 +15029,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15050,7 +15051,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15073,7 +15074,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15089,7 +15090,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15111,7 +15112,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15132,7 +15133,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15146,7 +15147,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-nats",
@@ -15181,7 +15182,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15206,7 +15207,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15234,7 +15235,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15256,7 +15257,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15276,7 +15277,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15314,7 +15315,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15342,7 +15343,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"lazy_static",
"serde",
@@ -15354,12 +15355,11 @@ dependencies = [
[[package]]
name = "windmill-api-users"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"argon2",
"axum 0.8.9",
"chrono",
- "dashmap",
"http 1.5.0",
"hyper 1.11.0",
"lazy_static",
@@ -15379,7 +15379,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15393,7 +15393,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15428,7 +15428,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"chrono",
"lazy_static",
@@ -15442,7 +15442,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15461,7 +15461,7 @@ dependencies = [
[[package]]
name = "windmill-common"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15565,7 +15565,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -15584,7 +15584,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"regex",
"serde",
@@ -15599,7 +15599,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15623,7 +15623,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"futures",
@@ -15640,7 +15640,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15656,7 +15656,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -15677,7 +15677,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -15708,7 +15708,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"arc-swap",
@@ -15733,7 +15733,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-stream",
@@ -15767,7 +15767,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"futures",
@@ -15785,7 +15785,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15794,7 +15794,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15806,7 +15806,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"serde_json",
@@ -15818,7 +15818,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"gosyn",
@@ -15830,7 +15830,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15842,7 +15842,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"serde_json",
@@ -15854,7 +15854,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"nu-parser",
@@ -15865,7 +15865,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15876,7 +15876,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15888,7 +15888,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15899,7 +15899,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -15921,7 +15921,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"serde_json",
@@ -15933,7 +15933,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15947,7 +15947,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15964,7 +15964,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15977,7 +15977,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"serde",
@@ -15989,7 +15989,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -16007,7 +16007,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16023,7 +16023,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16039,7 +16039,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -16053,7 +16053,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -16092,7 +16092,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"const_format",
@@ -16132,7 +16132,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16143,7 +16143,7 @@ dependencies = [
[[package]]
name = "windmill-store"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -16178,7 +16178,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16202,7 +16202,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16235,7 +16235,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16262,7 +16262,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16295,7 +16295,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16315,7 +16315,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16349,7 +16349,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16385,7 +16385,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16408,7 +16408,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16432,7 +16432,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-nats",
@@ -16456,7 +16456,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16491,7 +16491,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16519,7 +16519,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16544,7 +16544,7 @@ dependencies = [
[[package]]
name = "windmill-types"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"bitflags 2.13.1",
@@ -16563,7 +16563,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16679,7 +16679,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"bytes",
"futures",
@@ -17234,9 +17234,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
-version = "0.6.3"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "wyz"
@@ -17440,9 +17440,9 @@ dependencies = [
[[package]]
name = "zerotrie"
-version = "0.2.4"
+version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
@@ -17451,9 +17451,9 @@ dependencies = [
[[package]]
name = "zerovec"
-version = "0.11.6"
+version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
dependencies = [
"yoke",
"zerofrom",
@@ -17462,13 +17462,13 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.3"
+version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.3",
]
[[package]]
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index f41ff1c2ab..4bf21a1495 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "windmill"
-version = "1.789.0"
+version = "1.792.2"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
-version = "1.789.0"
+version = "1.792.2"
authors = ["Ruben Fiszel "]
edition = "2021"
diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt
index 6f6c280541..ec7c2ac03e 100644
--- a/backend/ee-repo-ref.txt
+++ b/backend/ee-repo-ref.txt
@@ -1 +1 @@
-a65162b22b127b54c0686095ee1b16b04e3111f7
+bd4de74eb37b32a2b6c7c69f6dedac031ef8436b
diff --git a/backend/migrations/20260813195023_preserve_orphaned_instance_group_members.down.sql b/backend/migrations/20260813195023_preserve_orphaned_instance_group_members.down.sql
new file mode 100644
index 0000000000..c2eb12a15a
--- /dev/null
+++ b/backend/migrations/20260813195023_preserve_orphaned_instance_group_members.down.sql
@@ -0,0 +1,9 @@
+-- Restore the instance_group source for members the up migration converted. The stripped
+-- auto_invite references cannot be restored (the groups they named no longer exist).
+UPDATE usr
+SET added_via = jsonb_build_object(
+ 'source', 'instance_group',
+ 'group', added_via->>'migrated_from_instance_group'
+)
+WHERE added_via->>'source' = 'manual'
+ AND added_via ? 'migrated_from_instance_group';
diff --git a/backend/migrations/20260813195023_preserve_orphaned_instance_group_members.up.sql b/backend/migrations/20260813195023_preserve_orphaned_instance_group_members.up.sql
new file mode 100644
index 0000000000..581401b854
--- /dev/null
+++ b/backend/migrations/20260813195023_preserve_orphaned_instance_group_members.up.sql
@@ -0,0 +1,54 @@
+-- Strip auto_invite references to groups that no longer exist, so a later group created
+-- with the same name cannot silently re-acquire the mapping.
+UPDATE workspace_settings
+SET auto_invite = jsonb_set(
+ jsonb_set(
+ auto_invite,
+ '{instance_groups}',
+ COALESCE(
+ (SELECT jsonb_agg(elem)
+ FROM jsonb_array_elements(auto_invite->'instance_groups') elem
+ WHERE EXISTS (SELECT 1 FROM instance_group ig WHERE ig.name = elem #>> '{}')),
+ '[]'::jsonb
+ )
+ ),
+ '{instance_groups_roles}',
+ CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'
+ THEN (SELECT COALESCE(jsonb_object_agg(key, value), '{}'::jsonb)
+ FROM jsonb_each(auto_invite->'instance_groups_roles')
+ WHERE EXISTS (SELECT 1 FROM instance_group ig WHERE ig.name = key))
+ ELSE '{}'::jsonb
+ END
+)
+WHERE jsonb_typeof(auto_invite->'instance_groups') = 'array'
+ AND EXISTS (
+ SELECT 1 FROM jsonb_array_elements(auto_invite->'instance_groups') elem
+ WHERE NOT EXISTS (SELECT 1 FROM instance_group ig WHERE ig.name = elem #>> '{}')
+ );
+
+-- Workspace members whose instance-group grant can no longer be re-derived become manual
+-- members. Group deletion, overwrite imports and some SCIM paths used to mutate groups
+-- without carrying the change through to workspace membership, leaving members whose
+-- granting group was deleted — or who were dropped from a group that still exists. Under
+-- state-based reconciliation those members belong to zero configured groups, so the first
+-- reconcile touching their workspace would otherwise remove them and destroy their drafts,
+-- favorites, tokens and permissions. The original group name is kept under
+-- 'migrated_from_instance_group' so admins can identify and prune them deliberately.
+UPDATE usr
+SET added_via = jsonb_build_object(
+ 'source', 'manual',
+ 'migrated_from_instance_group', added_via->>'group'
+)
+WHERE added_via->>'source' = 'instance_group'
+ AND NOT EXISTS (
+ SELECT 1
+ FROM workspace_settings ws
+ JOIN LATERAL jsonb_array_elements_text(
+ CASE WHEN jsonb_typeof(ws.auto_invite->'instance_groups') = 'array'
+ THEN ws.auto_invite->'instance_groups'
+ ELSE '[]'::jsonb
+ END
+ ) g ON true
+ JOIN email_to_igroup e ON e.igroup = g.value AND e.email = usr.email
+ WHERE ws.workspace_id = usr.workspace_id
+ );
diff --git a/backend/migrations/20260814090221_trigger_history.down.sql b/backend/migrations/20260814090221_trigger_history.down.sql
new file mode 100644
index 0000000000..c4aad347b0
--- /dev/null
+++ b/backend/migrations/20260814090221_trigger_history.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS trigger_history;
diff --git a/backend/migrations/20260814090221_trigger_history.up.sql b/backend/migrations/20260814090221_trigger_history.up.sql
new file mode 100644
index 0000000000..1eb4f778a2
--- /dev/null
+++ b/backend/migrations/20260814090221_trigger_history.up.sql
@@ -0,0 +1,65 @@
+-- Append-only record of every schedule/trigger mutation: who, what changed, and
+-- from which kind of client.
+CREATE TABLE IF NOT EXISTS trigger_history (
+ id BIGSERIAL PRIMARY KEY,
+ workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
+ -- 'schedule' or a trigger's TRIGGER_TYPE ('http', 'kafka', ...). Not the
+ -- TRIGGER_KIND enum: that one is capture-oriented and misses 'schedule'.
+ trigger_kind VARCHAR(50) NOT NULL,
+ path VARCHAR(255) NOT NULL,
+ -- 'create' | 'update' | 'delete' | 'enable' | 'disable' | 'suspend'
+ operation VARCHAR(20) NOT NULL,
+ -- 'ui' | 'cli' | 'api' | 'worker'
+ source VARCHAR(20) NOT NULL,
+ -- NULL when the server acted on its own (worker auto-disable).
+ username VARCHAR(255),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ -- {field: {old, new}} for the fields that actually changed. `old` is
+ -- absent where it is not known: a create, and the workspace-wide handler
+ -- override that rewrites every schedule without reading them first. NULL
+ -- when the operation carries no field-level diff at all (delete).
+ changes JSONB
+);
+
+CREATE INDEX IF NOT EXISTS idx_trigger_history_workspace_kind_path
+ ON trigger_history(workspace_id, trigger_kind, path, id DESC);
+
+CREATE INDEX IF NOT EXISTS idx_trigger_history_workspace_id
+ ON trigger_history(workspace_id, id DESC);
+
+GRANT ALL ON TABLE trigger_history TO windmill_user;
+GRANT ALL ON TABLE trigger_history TO windmill_admin;
+GRANT ALL ON SEQUENCE trigger_history_id_seq TO windmill_user;
+GRANT ALL ON SEQUENCE trigger_history_id_seq TO windmill_admin;
+
+ALTER TABLE trigger_history ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY admin_all ON trigger_history FOR ALL TO windmill_admin USING (true) WITH CHECK (true);
+
+-- Every mutating trigger route writes through the RLS pool, so windmill_user
+-- must be able to append.
+CREATE POLICY allow_insert ON trigger_history FOR INSERT TO windmill_user WITH CHECK (true);
+
+-- Reads mirror the path half of the live trigger's own policies: a row can
+-- quote a schedule's `args`, so it must not be readable by anyone who could not
+-- read the trigger it describes. Deliberately narrower than the live row on one
+-- point — the `extra_perms` grants have no counterpart here, since the history
+-- does not carry the row's ACL and must survive its deletion. Narrower is the
+-- safe direction.
+CREATE POLICY see_own ON trigger_history FOR SELECT TO windmill_user
+USING (
+ SPLIT_PART(path::text, '/', 1) = 'u'
+ AND SPLIT_PART(path::text, '/', 2) = current_setting('session.user')
+);
+
+CREATE POLICY see_member ON trigger_history FOR SELECT TO windmill_user
+USING (
+ SPLIT_PART(path::text, '/', 1) = 'g'
+ AND SPLIT_PART(path::text, '/', 2) = ANY(regexp_split_to_array(current_setting('session.groups'), ','))
+);
+
+CREATE POLICY see_folder_extra_perms_user ON trigger_history FOR SELECT TO windmill_user
+USING (
+ SPLIT_PART(path::text, '/', 1) = 'f'
+ AND SPLIT_PART(path::text, '/', 2) = ANY(regexp_split_to_array(current_setting('session.folders_read'), ','))
+);
diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json
index 9e74822d98..cec49861cd 100644
--- a/backend/oauth_connect.json
+++ b/backend/oauth_connect.json
@@ -159,7 +159,8 @@
"sage_intacct": {
"auth_url": "https://api.intacct.com/ia/api/v1/oauth2/authorize",
"token_url": "https://api.intacct.com/ia/api/v1/oauth2/token",
- "scopes": ["offline_access"]
+ "scopes": ["offline_access"],
+ "req_body_auth": true
},
"spotify": {
"auth_url": "https://accounts.spotify.com/authorize",
diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock
index c26c76cda3..9f7870863e 100644
--- a/backend/parsers/windmill-parser-wasm/Cargo.lock
+++ b/backend/parsers/windmill-parser-wasm/Cargo.lock
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
-version = "1.789.0"
+version = "1.792.2"
dependencies = [
"anyhow",
"bitflags",
diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml
index 1a50255db5..03243d5824 100644
--- a/backend/parsers/windmill-parser-wasm/Cargo.toml
+++ b/backend/parsers/windmill-parser-wasm/Cargo.toml
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
-version = "1.789.0"
+version = "1.792.2"
edition = "2021"
authors = ["Ruben Fiszel "]
diff --git a/backend/src/main.rs b/backend/src/main.rs
index 43a92ce4bf..2dff3209ac 100644
--- a/backend/src/main.rs
+++ b/backend/src/main.rs
@@ -1629,6 +1629,10 @@ Windmill Community Edition {GIT_VERSION}
}
}
+ // `workers_f` must stay ahead of `server_f`: these are polled on one task in
+ // declaration order, and `run_server` yields once after handing over the base
+ // internal url so the workers get past that oneshot before it builds its router.
+ // Ordering `server_f` first makes them wait out the whole build instead.
if mcp_mode {
futures::try_join!(workers_f, server_f)?;
} else {
@@ -1691,7 +1695,8 @@ async fn process_notify_event(
"restart_worker_group" => {
if worker_mode && payload == *WORKER_GROUP {
tracing::info!("Restart requested for worker group '{payload}'");
- spawn_graceful_killpill(tx, db, 30, "worker group restart requested").await;
+ spawn_graceful_killpill(tx, db, 30, "worker group restart requested", server_mode)
+ .await;
}
}
"notify_webhook_change" => {
@@ -1997,8 +2002,14 @@ async fn process_notify_event(
reload_otel_tracing_proxy_setting(conn).await;
if worker_mode {
tracing::info!("OTEL tracing proxy setting changed, restarting worker");
- spawn_graceful_killpill(tx, db, 30, "OTEL tracing proxy setting change")
- .await;
+ spawn_graceful_killpill(
+ tx,
+ db,
+ 30,
+ "OTEL tracing proxy setting change",
+ server_mode,
+ )
+ .await;
}
}
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => {
@@ -2009,12 +2020,20 @@ async fn process_notify_event(
}
EXPOSE_METRICS_SETTING => {
tracing::info!("Metrics setting changed, restarting");
- spawn_graceful_killpill(tx, db, 30, "metrics setting change").await;
+ spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode)
+ .await;
}
EMAIL_DOMAIN_SETTING => {
tracing::info!("Email domain setting changed");
if server_mode {
- spawn_graceful_killpill(tx, db, 30, "email domain setting change").await;
+ spawn_graceful_killpill(
+ tx,
+ db,
+ 30,
+ "email domain setting change",
+ server_mode,
+ )
+ .await;
}
}
EXPOSE_DEBUG_METRICS_SETTING => {
@@ -2050,19 +2069,26 @@ async fn process_notify_event(
}
OTEL_SETTING => {
tracing::info!("OTEL setting changed, restarting");
- spawn_graceful_killpill(tx, db, 30, "OTEL setting change").await;
+ spawn_graceful_killpill(tx, db, 30, "OTEL setting change", server_mode).await;
}
REQUEST_SIZE_LIMIT_SETTING => {
if server_mode {
tracing::info!("Request limit size change detected, killing server expecting to be restarted");
- spawn_graceful_killpill(tx, db, 30, "request size limit change").await;
+ spawn_graceful_killpill(
+ tx,
+ db,
+ 30,
+ "request size limit change",
+ server_mode,
+ )
+ .await;
}
}
SAML_METADATA_SETTING => {
tracing::info!(
"SAML metadata change detected, killing server expecting to be restarted"
);
- spawn_graceful_killpill(tx, db, 30, "SAML metadata change").await;
+ spawn_graceful_killpill(tx, db, 30, "SAML metadata change", server_mode).await;
}
HUB_BASE_URL_SETTING => {
if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await {
@@ -2183,12 +2209,7 @@ pub async fn run_workers(
// #[cfg(tokio_unstable)]
// let monitor = tokio_metrics::TaskMonitor::new();
- let ip = windmill_common::external_ip::get_ip()
- .await
- .unwrap_or_else(|e| {
- tracing::warn!(error = e.to_string(), "failed to get external IP");
- "unretrievable IP".to_string()
- });
+ windmill_common::external_ip::resolve_ip_in_background();
let mut handles = Vec::with_capacity(num_workers as usize);
@@ -2232,7 +2253,6 @@ pub async fn run_workers(
let conn1 = wk_conf.conn.clone();
let worker_name = wk_conf.worker_name.clone();
WORKERS_NAMES.write().await.push(worker_name.clone());
- let ip = ip.clone();
let rx = killpill_rxs.pop().unwrap();
let tx = tx.clone();
let base_internal_url = base_internal_url.clone();
@@ -2249,7 +2269,6 @@ pub async fn run_workers(
worker_name,
i as u64,
num_workers as u32,
- &ip,
rx,
tx,
&base_internal_url,
@@ -2286,16 +2305,24 @@ pub async fn run_workers(
/// then the sleep+kill is spawned in the background so the notification handler is not blocked.
///
/// Falls back to drain-only delay if DB coordination fails.
+///
+/// Only `server_mode` processes coordinate, on the strength of the worker case: a worker
+/// group restarting costs queue latency rather than lost work, `v2_job_queue` being durable.
+/// Were workers to take part, one could claim the `is_first` slot and leave every server
+/// holding its shutdown open for a peer that serves no API traffic.
async fn spawn_graceful_killpill(
tx: &KillpillSender,
db: &Pool,
safety_margin_secs: u64,
context: &str,
+ server_mode: bool,
) {
// Minimum delay before any restart to let in-flight requests drain
const DRAIN_DELAY_SECS: u64 = 3;
- let (delay, is_first) =
+ let (delay, is_first) = if !server_mode {
+ (DRAIN_DELAY_SECS, true)
+ } else {
match coordinate_restart_delay(db, safety_margin_secs, DRAIN_DELAY_SECS).await {
Ok(r) => r,
Err(e) => {
@@ -2305,7 +2332,8 @@ async fn spawn_graceful_killpill(
);
(DRAIN_DELAY_SECS, true)
}
- };
+ }
+ };
tracing::info!(
"Scheduling {context} graceful shutdown in {delay}s (first_to_restart={is_first})"
diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs
index a9f747cfe5..93ad5945b8 100644
--- a/backend/src/monitor.rs
+++ b/backend/src/monitor.rs
@@ -12,7 +12,7 @@ use std::{
};
use chrono::{DateTime, NaiveDateTime, Utc};
-use futures::{stream::FuturesUnordered, StreamExt};
+use futures::{future::BoxFuture, stream::FuturesUnordered, StreamExt};
use serde::{de::DeserializeOwned, Deserialize};
use sqlx::{Pool, Postgres};
use tokio::{
@@ -37,7 +37,6 @@ use windmill_common::ee_oss::low_disk_alerts;
#[cfg(feature = "enterprise")]
use windmill_common::ee_oss::{jobs_waiting_alerts, worker_groups_alerts};
-#[cfg(feature = "oauth2")]
use windmill_common::global_settings::OAUTH_SETTING;
use windmill_common::otel_oss::{
otel_incr_zombie_delete_count, otel_incr_zombie_restart_count, otel_set_db_pool,
@@ -52,13 +51,14 @@ use windmill_common::{
error,
flow_status::{FlowStatus, FlowStatusModule},
global_settings::{
- AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING,
- BUN_INSTALL_MIN_RELEASE_AGE_SETTING, CONCURRENCY_KEY_MAX_QUEUED_SETTING,
- CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
- CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING,
- DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
- DISABLE_PASSWORD_LOGIN, DISABLE_PASSWORD_LOGIN_SETTING, EXPOSE_DEBUG_METRICS_SETTING,
- EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
+ get_or_create_jwt_secret, load_value_from_global_settings,
+ AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING,
+ BUNFIG_INSTALL_SCOPES_SETTING, BUN_INSTALL_MIN_RELEASE_AGE_SETTING,
+ CONCURRENCY_KEY_MAX_QUEUED_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
+ CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
+ CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
+ DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_PASSWORD_LOGIN, DISABLE_PASSWORD_LOGIN_SETTING,
+ EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING,
HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
@@ -70,8 +70,8 @@ use windmill_common::{
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING,
SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING,
- STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING,
- UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING,
+ SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
+ UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING,
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
WORKSPACE_MAX_QUEUED_JOBS_SETTING,
@@ -83,11 +83,11 @@ use windmill_common::{
server::load_smtp_config,
tracing_init::JSON_FMT,
users::truncate_token,
- utils::{empty_as_none, now_from_db, rd_string, report_critical_error, Mode, HUB_API_SECRET},
+ utils::{empty_as_none, now_from_db, report_critical_error, Mode, HUB_API_SECRET},
worker::{
load_env_vars, load_init_bash_from_env, load_periodic_bash_script_from_env,
load_periodic_bash_script_interval_from_env, load_whitelist_env_vars_from_env,
- load_worker_config, reload_custom_tags_setting, store_pull_query,
+ load_worker_config, store_pull_query,
store_suspended_pull_query, Connection, WorkerConfig, CLOUD_HOSTED,
CONCURRENCY_KEY_MAX_QUEUED, CONCURRENCY_KEY_MAX_QUEUED_DEFAULT, DEFAULT_TAGS_PER_WORKSPACE,
DEFAULT_TAGS_WORKSPACES, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, INDEXER_CONFIG,
@@ -225,6 +225,11 @@ lazy_static::lazy_static! {
.unwrap_or(20);
}
+/// Load every setting this process cares about, at startup and on every full-reload tick.
+///
+/// Reads are declared into a [`SettingsPass`] rather than issued one at a time, so the dozens
+/// this pass makes cost a single fetch. See [`SettingsPass`] for what declaring buys and what
+/// it requires of the order things are declared in.
pub async fn initial_load(
conn: &Connection,
tx: KillpillSender,
@@ -232,102 +237,134 @@ pub async fn initial_load(
server_mode: bool,
#[cfg(feature = "parquet")] disable_s3_store: bool,
) {
- if let Err(e) = reload_base_url_setting(&conn).await {
- tracing::error!("Error loading base url: {:?}", e)
- }
+ let mut pass = SettingsPass::new();
+
+ pass.settings(
+ &[OAUTH_SETTING, BASE_URL_SETTING],
+ false,
+ move |mut v| async move {
+ if let Err(e) = apply_base_url_setting(
+ conn,
+ v.remove(OAUTH_SETTING).flatten(),
+ v.remove(BASE_URL_SETTING).flatten(),
+ )
+ .await
+ {
+ tracing::error!("Error loading base url: {:?}", e)
+ }
+ },
+ );
+
+ pass.setting(CRITICAL_ERROR_CHANNELS_SETTING, false, |v| async move {
+ apply_critical_error_channels_setting(v)
+ });
+
+ pass.setting(EXPOSE_METRICS_SETTING, true, |v| async move {
+ apply_metrics_enabled(v)
+ });
+ pass.setting(EXPOSE_DEBUG_METRICS_SETTING, true, |v| async move {
+ apply_metrics_debug_enabled(v)
+ });
+ pass.setting(CRITICAL_ALERT_MUTE_UI_SETTING, true, |v| async move {
+ apply_critical_alert_mute_ui_setting(v)
+ });
+ pass.setting(
+ CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
+ true,
+ |v| async move { apply_critical_alerts_on_token_expiry_setting(v) },
+ );
if let Some(db) = conn.as_sql() {
- if let Err(e) = reload_critical_error_channels_setting(&db).await {
- tracing::error!("Could loading critical error emails setting: {:?}", e);
- }
- }
-
- if let Err(e) = load_metrics_enabled(conn).await {
- tracing::error!("Error loading expose metrics: {e:#}");
- }
-
- if let Err(e) = load_metrics_debug_enabled(conn).await {
- tracing::error!("Error loading expose debug metrics: {e:#}");
- }
-
- if let Err(e) = reload_critical_alert_mute_ui_setting(conn).await {
- tracing::error!("Error loading critical alert mute ui setting: {e:#}");
- }
-
- if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await {
- tracing::error!("Error loading critical alerts on token expiry setting: {e:#}");
- }
-
- if let Some(db) = conn.as_sql() {
- if let Err(e) = load_tag_per_workspace_enabled(db).await {
- tracing::error!("Error loading default tag per workpsace: {e:#}");
- }
-
- if let Err(e) = load_tag_per_workspace_workspaces(db).await {
- tracing::error!("Error loading default tag per workpsace workspaces: {e:#}");
- }
-
- if let Err(e) = load_fork_workspace_tag_append_fork_suffix(db).await {
- tracing::error!("Error loading fork workspace tag append fork suffix: {e:#}");
- }
-
- if let Err(e) = load_preview_tags_override(db).await {
- tracing::error!("Error loading preview tags override: {e:#}");
- }
+ pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move {
+ apply_tag_per_workspace_enabled(v)
+ });
+ pass.setting(DEFAULT_TAGS_WORKSPACES_SETTING, false, |v| async move {
+ apply_tag_per_workspace_workspaces(v)
+ });
+ pass.setting(
+ FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING,
+ false,
+ |v| async move { apply_fork_workspace_tag_append_fork_suffix(v) },
+ );
+ pass.setting(PREVIEW_TAGS_OVERRIDE_SETTING, false, |v| async move {
+ apply_preview_tags_override(v)
+ });
// Load per-workspace retention overrides before the first cleanup tick so a fresh server
// never sweeps globally without honoring configured longer-retention workspaces.
- if let Err(e) = load_retention_period_overrides(db).await {
- tracing::error!("Error loading per-workspace retention overrides: {e:#}");
- }
+ pass.action(async move {
+ if let Err(e) = load_retention_period_overrides(db).await {
+ tracing::error!("Error loading per-workspace retention overrides: {e:#}");
+ }
+ });
- // Workspace fairness (cloud-only). Load the percentage/duration/min knobs
- // *before* the enabled flag so that `load_workspace_fairness_enabled` reads
- // current values when re-storing the pull queries.
- if let Err(e) = load_workspace_fairness_max_percent(db).await {
- tracing::error!("Error loading workspace fairness max percent: {e:#}");
- }
- if let Err(e) = load_workspace_fairness_duration_secs(db).await {
- tracing::error!("Error loading workspace fairness duration secs: {e:#}");
- }
- if let Err(e) = load_workspace_fairness_min_total(db).await {
- tracing::error!("Error loading workspace fairness min total: {e:#}");
- }
- if let Err(e) = load_workspace_fairness_enabled(db).await {
- tracing::error!("Error loading workspace fairness enabled: {e:#}");
- }
+ // Workspace fairness (cloud-only). The percentage/duration/min knobs apply
+ // *before* the enabled flag so that `apply_workspace_fairness_enabled` reads
+ // current values when re-storing the pull queries, which declaration order gives us.
+ pass.setting(
+ WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING,
+ false,
+ |v| async move { apply_workspace_fairness_max_percent(v) },
+ );
+ pass.setting(
+ WORKSPACE_FAIRNESS_DURATION_SECS_SETTING,
+ false,
+ |v| async move { apply_workspace_fairness_duration_secs(v) },
+ );
+ pass.setting(
+ WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
+ false,
+ |v| async move { apply_workspace_fairness_min_total(v) },
+ );
+ pass.setting(WORKSPACE_FAIRNESS_ENABLED_SETTING, false, |v| {
+ apply_workspace_fairness_enabled(v)
+ });
- // Only the cloud reads this cap, so don't spend a query loading it anywhere else.
+ // Only the cloud reads these caps, so don't ask for them anywhere else.
if *CLOUD_HOSTED {
- if let Err(e) = load_concurrency_key_max_queued(db).await {
- tracing::error!("Error loading concurrency key max queued: {e:#}");
- }
- if let Err(e) = load_workspace_max_queued_jobs(db).await {
- tracing::error!("Error loading workspace max queued jobs: {e:#}");
- }
+ pass.setting(CONCURRENCY_KEY_MAX_QUEUED_SETTING, false, |v| async move {
+ apply_concurrency_key_max_queued(v)
+ });
+ pass.setting(WORKSPACE_MAX_QUEUED_JOBS_SETTING, false, |v| async move {
+ apply_workspace_max_queued_jobs(v)
+ });
}
}
if server_mode {
if let Some(db) = conn.as_sql() {
- load_require_preexisting_user(db).await;
- load_disable_password_login(db).await;
- if let Err(e) = reload_critical_alerts_on_db_oversize(db).await {
- tracing::error!(
- "Error reloading critical alerts on db oversize setting: {:?}",
- e
- )
- }
- windmill_common::min_version::store_min_keep_alive_version(db).await;
- reload_instance_events_webhook_setting(db).await;
+ pass.setting(
+ REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
+ false,
+ |v| async move { apply_require_preexisting_user(v) },
+ );
+ pass.setting(DISABLE_PASSWORD_LOGIN_SETTING, false, |v| async move {
+ apply_disable_password_login(v)
+ });
+ pass.action(async move {
+ if let Err(e) = reload_critical_alerts_on_db_oversize(db).await {
+ tracing::error!(
+ "Error reloading critical alerts on db oversize setting: {:?}",
+ e
+ )
+ }
+ });
+ pass.action(windmill_common::min_version::store_min_keep_alive_version(db));
+ pass.setting(
+ windmill_common::global_settings::INSTANCE_EVENTS_WEBHOOK_SETTING,
+ false,
+ |v| async move { apply_instance_events_webhook_setting(v) },
+ );
}
}
if worker_mode {
- load_keep_job_dir(conn).await;
+ pass.setting(KEEP_JOB_DIR_SETTING, true, |v| async move {
+ apply_keep_job_dir(v)
+ });
match conn {
Connection::Sql(db) => {
- reload_worker_config(&db, tx, false).await;
+ pass.action(reload_worker_config(&db, tx, false));
}
Connection::Http(_) => {
// TODO: reload worker config from http
@@ -359,61 +396,119 @@ pub async fn initial_load(
}
}
- if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await {
- tracing::error!("Error reloading hub base url: {:?}", e)
- }
+ pass.setting(HUB_BASE_URL_SETTING, true, move |v| async move {
+ if let Err(e) = apply_hub_base_url_setting(conn, server_mode, v).await {
+ tracing::error!("Error reloading hub base url: {:?}", e)
+ }
+ });
if let Some(db) = conn.as_sql() {
- if let Err(e) = reload_jwt_secret_setting(db).await {
- tracing::error!("Could not reload jwt secret setting: {:?}", e);
- }
+ pass.setting(JWT_SECRET_SETTING, false, move |v| async move {
+ if let Err(e) = apply_jwt_secret_setting(db, v).await {
+ tracing::error!("Could not reload jwt secret setting: {:?}", e);
+ }
+ });
- if let Err(e) = reload_custom_tags_setting(db).await {
- tracing::error!("Error reloading custom tags: {:?}", e)
- }
-
- if let Err(e) = reload_app_workspaced_route_setting(db).await {
- tracing::error!("Error reloading app workspaced route: {:?}", e)
- }
-
- if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
- tracing::error!("Error reloading http route workspaced route: {:?}", e)
- }
+ pass.setting(CUSTOM_TAGS_SETTING, false, |v| async move {
+ windmill_common::worker::apply_custom_tags_setting(v)
+ });
+ pass.setting(APP_WORKSPACED_ROUTE_SETTING, false, |v| async move {
+ apply_app_workspaced_route_setting(v)
+ });
+ pass.setting(
+ HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
+ false,
+ move |v| async move {
+ if let Err(e) = apply_http_route_workspaced_route_setting(db, v).await {
+ tracing::error!("Error reloading http route workspaced route: {:?}", e)
+ }
+ },
+ );
}
+ // A step rather than a plain await: an AWS OIDC store mints its first token against an
+ // issuer built from `BASE_URL` (`oidc_ee.rs`), and with `OTEL_ENVIRONMENT` set nothing
+ // loads that before this pass does, so running ahead of the applier would sign with the
+ // unset default and fall back to the 10s retry.
#[cfg(feature = "parquet")]
if !disable_s3_store {
if let Some(db) = conn.as_sql() {
let db2 = db.clone();
- match reload_object_store_setting(db).await {
- ObjectStoreReload::Later => {
- tokio::spawn(async move {
- tokio::time::sleep(Duration::from_secs(10)).await;
- match reload_object_store_setting(&db2).await {
- ObjectStoreReload::Later => {
- tracing::error!("Giving up on loading object store setting");
+ pass.action(async move {
+ match reload_object_store_setting(db).await {
+ ObjectStoreReload::Later => {
+ tokio::spawn(async move {
+ tokio::time::sleep(Duration::from_secs(10)).await;
+ match reload_object_store_setting(&db2).await {
+ ObjectStoreReload::Later => {
+ tracing::error!("Giving up on loading object store setting");
+ }
+ ObjectStoreReload::Never => {
+ tracing::info!("Object store setting successfully loaded");
+ }
}
- ObjectStoreReload::Never => {
- tracing::info!("Object store setting successfully loaded");
- }
- }
- });
+ });
+ }
+ ObjectStoreReload::Never => (),
}
- ObjectStoreReload::Never => (),
- }
+ });
}
}
if let Some(db) = conn.as_sql() {
- reload_smtp_config(db).await;
+ let _ = db;
+ pass.setting(SMTP_SETTING, false, |v| async move {
+ tracing::info!("Reloading smtp config...");
+ SMTP_CONFIG.store(std::sync::Arc::new(
+ windmill_common::server::parse_smtp_config(v),
+ ));
+ });
}
- reload_hub_api_secret_setting(&conn).await;
+ pass.option_setting_with(
+ HUB_API_SECRET_SETTING,
+ "HUB_API_SECRET",
+ |v: Option| async move { HUB_API_SECRET.store(std::sync::Arc::new(v)) },
+ );
if server_mode {
- reload_retention_period_setting(&conn).await;
- reload_audit_log_retention_days_setting(&conn).await;
- reload_store_audit_logs_s3_setting(&conn).await;
+ pass.setting(RETENTION_PERIOD_SECS_SETTING, true, |v| async move {
+ JOB_RETENTION_SECS.store(
+ parse_setting_value::(
+ v,
+ RETENTION_PERIOD_SECS_SETTING,
+ "JOB_RETENTION_SECS",
+ 60 * 60 * 24 * 30,
+ |x| x,
+ ),
+ Ordering::Relaxed,
+ )
+ });
+ pass.setting(AUDIT_LOG_RETENTION_DAYS_SETTING, true, |v| async move {
+ AUDIT_LOG_RETENTION_DAYS.store(
+ // 0 means use default: 365 for EE, 14 for CE
+ parse_setting_value::(
+ v,
+ AUDIT_LOG_RETENTION_DAYS_SETTING,
+ "AUDIT_LOG_RETENTION_DAYS",
+ 0,
+ |x| x,
+ ),
+ Ordering::Relaxed,
+ )
+ });
+ pass.setting(STORE_AUDIT_LOGS_S3_SETTING, true, |v| async move {
+ STORE_AUDIT_LOGS_S3.store(
+ parse_setting_value::(
+ v,
+ STORE_AUDIT_LOGS_S3_SETTING,
+ "STORE_AUDIT_LOGS_S3",
+ false,
+ |x| x,
+ ),
+ Ordering::Relaxed,
+ )
+ });
// Env-var enable has no settings-row xmin and no runtime enable event;
// anchor the export cursor at startup so rows committed before the
// first export tick are not skipped (no-op when a settings row exists
@@ -421,66 +516,177 @@ pub async fn initial_load(
// Enterprise feature; the core logic lives in `crate::ee` (OSS gets a
// no-op), gated here on a valid Enterprise license.
#[cfg(feature = "parquet")]
- if STORE_AUDIT_LOGS_S3.load(std::sync::atomic::Ordering::Relaxed)
- && matches!(
- windmill_common::ee_oss::get_license_plan().await,
- windmill_common::ee_oss::LicensePlan::Enterprise
- )
- {
- if let Some(db) = conn.as_sql() {
- crate::ee_oss::anchor_audit_logs_s3_checkpoint_env_var(&db).await;
- }
- }
- reload_request_size(&conn).await;
- reload_saml_metadata_setting(&conn).await;
- reload_scim_token_setting(&conn).await;
-
- // Ensure audit partitions exist before any requests arrive
if let Some(db) = conn.as_sql() {
- manage_audit_partitions(&db, audit_log_retention_days().await).await;
+ pass.action(async move {
+ if STORE_AUDIT_LOGS_S3.load(std::sync::atomic::Ordering::Relaxed)
+ && matches!(
+ windmill_common::ee_oss::get_license_plan().await,
+ windmill_common::ee_oss::LicensePlan::Enterprise
+ )
+ {
+ crate::ee_oss::anchor_audit_logs_s3_checkpoint_env_var(&db).await;
+ }
+ });
+ }
+ pass.required_setting(
+ REQUEST_SIZE_LIMIT_SETTING,
+ "REQUEST_SIZE_LIMIT",
+ DEFAULT_BODY_LIMIT,
+ REQUEST_SIZE_LIMIT.clone(),
+ |x| x.mul(1024 * 1024),
+ );
+ pass.option_setting(
+ SAML_METADATA_SETTING,
+ "SAML_METADATA",
+ SAML_METADATA.clone(),
+ );
+ pass.option_setting(SCIM_TOKEN_SETTING, "SCIM_TOKEN", SCIM_TOKEN.clone());
+
+ // Ensure audit partitions exist before any requests arrive. A step rather than a
+ // plain await: it drops partitions past `audit_log_retention_days`, so running it
+ // before that setting is applied would sweep with the compile-time default.
+ if let Some(db) = conn.as_sql() {
+ pass.action(async move {
+ manage_audit_partitions(&db, audit_log_retention_days().await).await
+ });
}
}
if worker_mode {
- reload_job_default_timeout_setting(&conn).await;
- reload_job_isolation_setting(&conn).await;
- reload_nsjail_tmpfs_size_setting(&conn).await;
- reload_nsjail_tmp_backing_setting(&conn).await;
- reload_sandbox_image_max_size_setting(&conn).await;
- reload_sandbox_image_cache_max_setting(&conn).await;
- reload_sandbox_image_pull_policy_setting(&conn).await;
- reload_sandbox_image_default_registry_setting(&conn).await;
- reload_sandbox_registry_auth_setting(&conn).await;
- reload_extra_pip_index_url_setting(&conn).await;
- reload_pip_index_url_setting(&conn).await;
- reload_uv_index_strategy_setting(&conn).await;
- reload_uv_exclude_newer_setting(&conn).await;
- reload_uv_python_install_mirror_setting(&conn).await;
- reload_bun_install_min_release_age_setting(&conn).await;
- reload_npm_config_registry_setting(&conn).await;
- reload_bunfig_install_scopes_setting(&conn).await;
- reload_npmrc_setting(&conn).await;
- reload_instance_python_version_setting(&conn).await;
- reload_nuget_config_setting(&conn).await;
- reload_powershell_repo_url_setting(&conn).await;
- reload_powershell_repo_pat_setting(&conn).await;
- reload_maven_repos_setting(&conn).await;
- reload_maven_settings_xml_setting(&conn).await;
- reload_no_default_maven_setting(&conn).await;
- reload_ruby_repos_setting(&conn).await;
- reload_cargo_registries_setting(&conn).await;
- reload_workspace_registries_setting(&conn).await;
+ use windmill_common::global_settings as gs;
+ pass.option_setting(
+ JOB_DEFAULT_TIMEOUT_SECS_SETTING,
+ "JOB_DEFAULT_TIMEOUT_SECS",
+ JOB_DEFAULT_TIMEOUT.clone(),
+ );
+ pass.setting(JOB_ISOLATION_SETTING, true, apply_job_isolation_setting);
+ pass.option_setting(
+ NSJAIL_TMPFS_SIZE_MB_SETTING,
+ "NSJAIL_TMPFS_SIZE_MB",
+ NSJAIL_TMPFS_SIZE_MB.clone(),
+ );
+ pass.option_setting(
+ NSJAIL_TMP_BACKING_SETTING,
+ "NSJAIL_TMP_BACKING",
+ NSJAIL_TMP_BACKING.clone(),
+ );
+ pass.option_setting(
+ SANDBOX_IMAGE_MAX_SIZE_MB_SETTING,
+ "SANDBOX_IMAGE_MAX_SIZE_MB",
+ SANDBOX_IMAGE_MAX_SIZE_MB.clone(),
+ );
+ pass.option_setting(
+ SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
+ "SANDBOX_IMAGE_CACHE_MAX_MB",
+ SANDBOX_IMAGE_CACHE_MAX_MB.clone(),
+ );
+ pass.option_setting(
+ SANDBOX_IMAGE_PULL_POLICY_SETTING,
+ "SANDBOX_IMAGE_PULL_POLICY",
+ SANDBOX_IMAGE_PULL_POLICY.clone(),
+ );
+ pass.option_setting(
+ SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING,
+ "SANDBOX_IMAGE_DEFAULT_REGISTRY",
+ SANDBOX_IMAGE_DEFAULT_REGISTRY.clone(),
+ );
+ pass.setting(
+ SANDBOX_REGISTRY_AUTH_SETTING,
+ true,
+ apply_sandbox_registry_auth_setting,
+ );
+ pass.option_setting(
+ EXTRA_PIP_INDEX_URL_SETTING,
+ "PIP_EXTRA_INDEX_URL",
+ PIP_EXTRA_INDEX_URL.clone(),
+ );
+ pass.option_setting(
+ PIP_INDEX_URL_SETTING,
+ "PIP_INDEX_URL",
+ PIP_INDEX_URL.clone(),
+ );
+ pass.option_setting(
+ UV_INDEX_STRATEGY_SETTING,
+ "UV_INDEX_STRATEGY",
+ UV_INDEX_STRATEGY.clone(),
+ );
+ pass.option_setting(
+ UV_EXCLUDE_NEWER_SETTING,
+ "UV_EXCLUDE_NEWER",
+ UV_EXCLUDE_NEWER.clone(),
+ );
+ pass.option_setting(
+ UV_PYTHON_INSTALL_MIRROR_SETTING,
+ "UV_PYTHON_INSTALL_MIRROR",
+ UV_PYTHON_INSTALL_MIRROR.clone(),
+ );
+ pass.option_setting(
+ BUN_INSTALL_MIN_RELEASE_AGE_SETTING,
+ "BUN_INSTALL_MIN_RELEASE_AGE",
+ BUN_INSTALL_MIN_RELEASE_AGE.clone(),
+ );
+ pass.option_setting(
+ NPM_CONFIG_REGISTRY_SETTING,
+ "NPM_CONFIG_REGISTRY",
+ NPM_CONFIG_REGISTRY.clone(),
+ );
+ pass.option_setting(
+ BUNFIG_INSTALL_SCOPES_SETTING,
+ "BUNFIG_INSTALL_SCOPES",
+ BUNFIG_INSTALL_SCOPES.clone(),
+ );
+ pass.option_setting(NPMRC_SETTING, "NPMRC", NPMRC.clone());
+ pass.option_setting(
+ INSTANCE_PYTHON_VERSION_SETTING,
+ "INSTANCE_PYTHON_VERSION",
+ INSTANCE_PYTHON_VERSION.clone(),
+ );
+ pass.option_setting(NUGET_CONFIG_SETTING, "NUGET_CONFIG", NUGET_CONFIG.clone());
+ pass.option_setting(
+ POWERSHELL_REPO_URL_SETTING,
+ "POWERSHELL_REPO_URL",
+ POWERSHELL_REPO_URL.clone(),
+ );
+ pass.option_setting(
+ POWERSHELL_REPO_PAT_SETTING,
+ "POWERSHELL_REPO_PAT",
+ POWERSHELL_REPO_PAT.clone(),
+ );
+ pass.option_setting(gs::MAVEN_REPOS_SETTING, "MAVEN_REPOS", MAVEN_REPOS.clone());
+ pass.option_setting(
+ gs::MAVEN_SETTINGS_XML_SETTING,
+ "MAVEN_SETTINGS_XML",
+ MAVEN_SETTINGS_XML.clone(),
+ );
+ pass.action(write_maven_settings_xml());
+ pass.setting(gs::NO_DEFAULT_MAVEN_SETTING, true, |v| async move {
+ apply_no_default_maven_setting(v)
+ });
+ pass.url_list_setting(
+ gs::RUBY_REPOS_SETTING,
+ "RUBY_REPOS",
+ windmill_worker::RUBY_REPOS.clone(),
+ );
+ pass.option_setting(
+ gs::CARGO_REGISTRIES_SETTING,
+ "CARGO_REGISTRIES",
+ CARGO_REGISTRIES.clone(),
+ );
+ pass.setting(
+ gs::WORKSPACE_REGISTRIES_SETTING,
+ true,
+ apply_workspace_registries_setting,
+ );
}
+
+ pass.run(conn).await;
}
-pub async fn load_metrics_enabled(conn: &Connection) -> error::Result<()> {
- let metrics_enabled =
- load_value_from_global_settings_with_conn(conn, EXPOSE_METRICS_SETTING, true).await;
- match metrics_enabled {
- Ok(Some(serde_json::Value::Bool(t))) => METRICS_ENABLED.store(t, Ordering::Relaxed),
- _ => (),
- };
- Ok(())
+
+pub fn apply_metrics_enabled(value: Option) {
+ if let Some(serde_json::Value::Bool(t)) = value {
+ METRICS_ENABLED.store(t, Ordering::Relaxed)
+ }
}
#[derive(serde::Deserialize)]
@@ -564,23 +770,26 @@ pub async fn load_otel(db: &DB) {
}
pub async fn load_tag_per_workspace_enabled(db: &DB) -> error::Result<()> {
- let metrics_enabled =
- load_value_from_global_settings(db, DEFAULT_TAGS_PER_WORKSPACE_SETTING).await;
-
- match metrics_enabled {
- Ok(Some(serde_json::Value::Bool(t))) => {
- DEFAULT_TAGS_PER_WORKSPACE.store(t, Ordering::Relaxed)
- }
- _ => (),
- };
+ let v = load_value_from_global_settings(db, DEFAULT_TAGS_PER_WORKSPACE_SETTING).await?;
+ apply_tag_per_workspace_enabled(v);
Ok(())
}
-pub async fn load_tag_per_workspace_workspaces(db: &DB) -> error::Result<()> {
- let workspaces = load_value_from_global_settings(db, DEFAULT_TAGS_WORKSPACES_SETTING).await;
+pub fn apply_tag_per_workspace_enabled(value: Option) {
+ if let Some(serde_json::Value::Bool(t)) = value {
+ DEFAULT_TAGS_PER_WORKSPACE.store(t, Ordering::Relaxed)
+ }
+}
- match workspaces {
- Ok(Some(serde_json::Value::Array(t))) => {
+pub async fn load_tag_per_workspace_workspaces(db: &DB) -> error::Result<()> {
+ let v = load_value_from_global_settings(db, DEFAULT_TAGS_WORKSPACES_SETTING).await?;
+ apply_tag_per_workspace_workspaces(v);
+ Ok(())
+}
+
+pub fn apply_tag_per_workspace_workspaces(value: Option) {
+ match value {
+ Some(serde_json::Value::Array(t)) => {
let workspaces = t
.iter()
.filter_map(|x| x.as_str())
@@ -588,24 +797,25 @@ pub async fn load_tag_per_workspace_workspaces(db: &DB) -> error::Result<()> {
.collect::>();
DEFAULT_TAGS_WORKSPACES.store(std::sync::Arc::new(Some(workspaces)));
}
- Ok(None) => {
+ None => {
DEFAULT_TAGS_WORKSPACES.store(std::sync::Arc::new(None));
}
_ => (),
};
- Ok(())
}
pub async fn load_preview_tags_override(db: &DB) -> error::Result<()> {
- let value = load_value_from_global_settings(db, PREVIEW_TAGS_OVERRIDE_SETTING).await;
-
- match value {
- Ok(Some(serde_json::Value::Bool(t))) => PREVIEW_TAGS_OVERRIDE.store(t, Ordering::Relaxed),
- _ => (),
- };
+ let v = load_value_from_global_settings(db, PREVIEW_TAGS_OVERRIDE_SETTING).await?;
+ apply_preview_tags_override(v);
Ok(())
}
+pub fn apply_preview_tags_override(value: Option) {
+ if let Some(serde_json::Value::Bool(t)) = value {
+ PREVIEW_TAGS_OVERRIDE.store(t, Ordering::Relaxed)
+ }
+}
+
// Upper bound on the duration window. Postgres `make_interval(secs => $1::int4)` is the consumer
// downstream, so this stays comfortably below `i32::MAX` and the subsequent `u32 -> i32` cast in
// `workspace_fairness::refresh_overloaded` cannot wrap into a negative interval (which would
@@ -635,12 +845,17 @@ pub async fn load_workspace_fairness_enabled(db: &DB) -> error::Result<()> {
// atomic untouched rather than silently toggling the feature off across the whole cluster
// (which would also trigger an unnecessary `store_pull_query` rebuild — exactly when DB load
// is probably highest).
- let new_enabled =
- match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_ENABLED_SETTING).await? {
- Some(serde_json::Value::Bool(t)) => t,
- // Setting unset / non-bool → explicit off.
- _ => false,
- };
+ let v = load_value_from_global_settings(db, WORKSPACE_FAIRNESS_ENABLED_SETTING).await?;
+ apply_workspace_fairness_enabled(v).await;
+ Ok(())
+}
+
+pub async fn apply_workspace_fairness_enabled(value: Option) {
+ let new_enabled = match value {
+ Some(serde_json::Value::Bool(t)) => t,
+ // Setting unset / non-bool → explicit off.
+ _ => false,
+ };
let prev = WORKSPACE_FAIRNESS_ENABLED.swap(new_enabled, Ordering::Relaxed);
// Re-store the pull queries so the fairness variants appear/disappear in
// lockstep with the toggle.
@@ -648,18 +863,23 @@ pub async fn load_workspace_fairness_enabled(db: &DB) -> error::Result<()> {
let wc = windmill_common::worker::WORKER_CONFIG.load_full();
store_pull_query(&wc).await;
}
- Ok(())
}
pub async fn load_workspace_fairness_max_percent(db: &DB) -> error::Result<()> {
- // Distinguish three outcomes:
- // - `Err(_)`: transient DB issue. Leave the atomic alone (don't clobber a known-good value
- // because of a network blip during a notify-event propagation).
- // - `Ok(None)` or `Ok(Some(invalid))`: setting is unset / explicitly cleared / corrupt.
- // Restore the default so a deletion via the admin UI actually takes effect at runtime
- // instead of leaving the stale in-memory value pinned until restart.
- // - `Ok(Some(valid))`: clamp and store.
- match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING).await? {
+ let v = load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING).await?;
+ apply_workspace_fairness_max_percent(v);
+ Ok(())
+}
+
+// Distinguish three outcomes:
+// - `Err(_)`: transient DB issue. Leave the atomic alone (don't clobber a known-good value
+// because of a network blip during a notify-event propagation).
+// - `Ok(None)` or `Ok(Some(invalid))`: setting is unset / explicitly cleared / corrupt.
+// Restore the default so a deletion via the admin UI actually takes effect at runtime
+// instead of leaving the stale in-memory value pinned until restart.
+// - `Ok(Some(valid))`: clamp and store.
+pub fn apply_workspace_fairness_max_percent(value: Option) {
+ match value {
Some(serde_json::Value::Number(n)) => {
let v = n
.as_u64()
@@ -672,12 +892,17 @@ pub async fn load_workspace_fairness_max_percent(db: &DB) -> error::Result<()> {
.store(WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT, Ordering::Relaxed);
}
}
- Ok(())
}
pub async fn load_workspace_fairness_duration_secs(db: &DB) -> error::Result<()> {
- // See `load_workspace_fairness_max_percent` for the Err / None / invalid policy.
- match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING).await? {
+ let v = load_value_from_global_settings(db, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING).await?;
+ apply_workspace_fairness_duration_secs(v);
+ Ok(())
+}
+
+// See `load_workspace_fairness_max_percent` for the Err / None / invalid policy.
+pub fn apply_workspace_fairness_duration_secs(value: Option) {
+ match value {
Some(serde_json::Value::Number(n)) => {
// Clamp to the safe range before narrowing. The downstream `u32 -> i32` cast in
// `workspace_fairness::refresh_overloaded` makes any value above `i32::MAX` toxic
@@ -693,12 +918,17 @@ pub async fn load_workspace_fairness_duration_secs(db: &DB) -> error::Result<()>
.store(WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT, Ordering::Relaxed);
}
}
- Ok(())
}
pub async fn load_workspace_fairness_min_total(db: &DB) -> error::Result<()> {
- // See `load_workspace_fairness_max_percent` for the Err / None / invalid policy.
- match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING).await? {
+ let v = load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING).await?;
+ apply_workspace_fairness_min_total(v);
+ Ok(())
+}
+
+// See `load_workspace_fairness_max_percent` for the Err / None / invalid policy.
+pub fn apply_workspace_fairness_min_total(value: Option) {
+ match value {
Some(serde_json::Value::Number(n)) => {
// Clamp before narrowing — same reasoning as `_duration_secs`, just for the
// counting threshold rather than the interval.
@@ -713,12 +943,17 @@ pub async fn load_workspace_fairness_min_total(db: &DB) -> error::Result<()> {
.store(WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT, Ordering::Relaxed);
}
}
- Ok(())
}
pub async fn load_concurrency_key_max_queued(db: &DB) -> error::Result<()> {
- // See `load_workspace_fairness_max_percent` for the Err / None / invalid policy.
- match load_value_from_global_settings(db, CONCURRENCY_KEY_MAX_QUEUED_SETTING).await? {
+ let v = load_value_from_global_settings(db, CONCURRENCY_KEY_MAX_QUEUED_SETTING).await?;
+ apply_concurrency_key_max_queued(v);
+ Ok(())
+}
+
+// See `load_workspace_fairness_max_percent` for the Err / None / invalid policy.
+pub fn apply_concurrency_key_max_queued(value: Option) {
+ match value {
Some(serde_json::Value::Number(n)) => {
// `0` is a meaningful value here (disable the cap), so unlike the fairness knobs
// the lower bound is 0 rather than 1.
@@ -746,7 +981,6 @@ pub async fn load_concurrency_key_max_queued(db: &DB) -> error::Result<()> {
CONCURRENCY_KEY_MAX_QUEUED.store(CONCURRENCY_KEY_MAX_QUEUED_DEFAULT, Ordering::Relaxed);
}
}
- Ok(())
}
pub async fn load_workspace_max_queued_jobs(db: &DB) -> error::Result<()> {
@@ -754,8 +988,14 @@ pub async fn load_workspace_max_queued_jobs(db: &DB) -> error::Result<()> {
if !*CLOUD_HOSTED {
return Ok(());
}
- // Same Err / None / invalid policy as load_concurrency_key_max_queued: 0 disables.
- match load_value_from_global_settings(db, WORKSPACE_MAX_QUEUED_JOBS_SETTING).await? {
+ let v = load_value_from_global_settings(db, WORKSPACE_MAX_QUEUED_JOBS_SETTING).await?;
+ apply_workspace_max_queued_jobs(v);
+ Ok(())
+}
+
+/// Same Err / None / invalid policy as [`apply_concurrency_key_max_queued`]: 0 disables.
+pub fn apply_workspace_max_queued_jobs(value: Option) {
+ match value {
Some(serde_json::Value::Number(n)) => {
let v = n
.as_u64()
@@ -779,52 +1019,67 @@ pub async fn load_workspace_max_queued_jobs(db: &DB) -> error::Result<()> {
WORKSPACE_MAX_QUEUED_JOBS.store(WORKSPACE_MAX_QUEUED_JOBS_DEFAULT, Ordering::Relaxed);
}
}
- Ok(())
}
pub async fn load_fork_workspace_tag_append_fork_suffix(db: &DB) -> error::Result<()> {
- let value =
- load_value_from_global_settings(db, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING).await;
-
- match value {
- Ok(Some(serde_json::Value::Bool(t))) => {
- FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX.store(t, Ordering::Relaxed)
- }
- Ok(None) => FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX.store(false, Ordering::Relaxed),
- _ => (),
- };
+ let v =
+ load_value_from_global_settings(db, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING).await?;
+ apply_fork_workspace_tag_append_fork_suffix(v);
Ok(())
}
+pub fn apply_fork_workspace_tag_append_fork_suffix(value: Option) {
+ match value {
+ Some(serde_json::Value::Bool(t)) => {
+ FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX.store(t, Ordering::Relaxed)
+ }
+ None => FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX.store(false, Ordering::Relaxed),
+ _ => (),
+ };
+}
+
pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error::Result<()> {
- if let Ok(Some(serde_json::Value::Bool(t))) =
- load_value_from_global_settings_with_conn(conn, CRITICAL_ALERT_MUTE_UI_SETTING, true).await
- {
+ let v =
+ load_value_from_global_settings_with_conn(conn, CRITICAL_ALERT_MUTE_UI_SETTING, true).await?;
+ apply_critical_alert_mute_ui_setting(v);
+ Ok(())
+}
+
+pub fn apply_critical_alert_mute_ui_setting(value: Option) {
+ if let Some(serde_json::Value::Bool(t)) = value {
CRITICAL_ALERT_MUTE_UI_ENABLED.store(t, Ordering::Relaxed);
}
- Ok(())
}
pub async fn reload_critical_alerts_on_token_expiry_setting(
conn: &Connection,
) -> error::Result<()> {
- if let Ok(Some(serde_json::Value::Bool(t))) = load_value_from_global_settings_with_conn(
+ let v = load_value_from_global_settings_with_conn(
conn,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
true,
)
- .await
- {
- CRITICAL_ALERTS_ON_TOKEN_EXPIRY.store(t, Ordering::Relaxed);
- }
+ .await?;
+ apply_critical_alerts_on_token_expiry_setting(v);
Ok(())
}
+pub fn apply_critical_alerts_on_token_expiry_setting(value: Option) {
+ if let Some(serde_json::Value::Bool(t)) = value {
+ CRITICAL_ALERTS_ON_TOKEN_EXPIRY.store(t, Ordering::Relaxed);
+ }
+}
+
pub async fn load_metrics_debug_enabled(conn: &Connection) -> error::Result<()> {
- let metrics_enabled =
- load_value_from_global_settings_with_conn(conn, EXPOSE_DEBUG_METRICS_SETTING, true).await;
- match metrics_enabled {
- Ok(Some(serde_json::Value::Bool(t))) => {
+ let v =
+ load_value_from_global_settings_with_conn(conn, EXPOSE_DEBUG_METRICS_SETTING, true).await?;
+ apply_metrics_debug_enabled(v);
+ Ok(())
+}
+
+pub fn apply_metrics_debug_enabled(value: Option) {
+ match value {
+ Some(serde_json::Value::Bool(t)) => {
METRICS_DEBUG_ENABLED.store(t, Ordering::Relaxed);
//_RJEM_MALLOC_CONF=prof:true,prof_active:false,lg_prof_interval:30,lg_prof_sample:21,prof_prefix:/tmp/jeprof
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
@@ -836,7 +1091,6 @@ pub async fn load_metrics_debug_enabled(conn: &Connection) -> error::Result<()>
}
_ => (),
};
- Ok(())
}
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
@@ -1132,16 +1386,18 @@ fn read_log_counters(ts_str: String) -> (usize, usize) {
}
pub async fn load_keep_job_dir(conn: &Connection) {
- let value = load_value_from_global_settings_with_conn(conn, KEEP_JOB_DIR_SETTING, true).await;
- match value {
- Ok(Some(serde_json::Value::Bool(t))) => KEEP_JOB_DIR.store(t, Ordering::Relaxed),
- Err(e) => {
- tracing::error!("Error loading keep job dir metrics: {e:#}");
- }
- _ => (),
+ match load_value_from_global_settings_with_conn(conn, KEEP_JOB_DIR_SETTING, true).await {
+ Ok(v) => apply_keep_job_dir(v),
+ Err(e) => tracing::error!("Error loading keep job dir metrics: {e:#}"),
};
}
+pub fn apply_keep_job_dir(value: Option) {
+ if let Some(serde_json::Value::Bool(t)) = value {
+ KEEP_JOB_DIR.store(t, Ordering::Relaxed)
+ }
+}
+
pub async fn reload_otel_tracing_proxy_setting(conn: &Connection) {
match load_value_from_global_settings_with_conn(conn, OTEL_TRACING_PROXY_SETTING, true).await {
Ok(Some(settings)) => match serde_json::from_value::(settings) {
@@ -1176,27 +1432,29 @@ pub async fn reload_otel_tracing_proxy_setting(conn: &Connection) {
}
pub async fn load_require_preexisting_user(db: &DB) {
- let value =
- load_value_from_global_settings(db, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING).await;
- match value {
- Ok(Some(serde_json::Value::Bool(t))) => {
- REQUIRE_PREEXISTING_USER_FOR_OAUTH.store(t, Ordering::Relaxed)
- }
- Err(e) => {
- tracing::error!("Error loading keep job dir metrics: {e:#}");
- }
- _ => (),
+ match load_value_from_global_settings(db, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING).await {
+ Ok(v) => apply_require_preexisting_user(v),
+ Err(e) => tracing::error!("Error loading require_preexisting_user setting: {e:#}"),
};
}
+pub fn apply_require_preexisting_user(value: Option) {
+ if let Some(serde_json::Value::Bool(t)) = value {
+ REQUIRE_PREEXISTING_USER_FOR_OAUTH.store(t, Ordering::Relaxed)
+ }
+}
+
pub async fn load_disable_password_login(db: &DB) {
- let value = load_value_from_global_settings(db, DISABLE_PASSWORD_LOGIN_SETTING).await;
+ match load_value_from_global_settings(db, DISABLE_PASSWORD_LOGIN_SETTING).await {
+ Ok(v) => apply_disable_password_login(v),
+ Err(e) => tracing::error!("Error loading disable_password_login setting: {e:#}"),
+ };
+}
+
+pub fn apply_disable_password_login(value: Option) {
match value {
- Ok(Some(serde_json::Value::Bool(t))) => DISABLE_PASSWORD_LOGIN.store(t, Ordering::Relaxed),
- Ok(None) => DISABLE_PASSWORD_LOGIN.store(false, Ordering::Relaxed),
- Err(e) => {
- tracing::error!("Error loading disable_password_login setting: {e:#}");
- }
+ Some(serde_json::Value::Bool(t)) => DISABLE_PASSWORD_LOGIN.store(t, Ordering::Relaxed),
+ None => DISABLE_PASSWORD_LOGIN.store(false, Ordering::Relaxed),
_ => (),
};
}
@@ -2203,22 +2461,25 @@ async fn delete_log_files_from_disk_and_store(
pub async fn reload_instance_events_webhook_setting(db: &DB) {
use windmill_common::global_settings::INSTANCE_EVENTS_WEBHOOK_SETTING;
- use windmill_common::webhook::INSTANCE_EVENTS_WEBHOOK;
- let value = load_value_from_global_settings(db, INSTANCE_EVENTS_WEBHOOK_SETTING).await;
+ match load_value_from_global_settings(db, INSTANCE_EVENTS_WEBHOOK_SETTING).await {
+ Ok(v) => apply_instance_events_webhook_setting(v),
+ Err(e) => tracing::error!("Error loading instance_events_webhook setting: {e:#}"),
+ }
+}
+
+pub fn apply_instance_events_webhook_setting(value: Option) {
+ use windmill_common::webhook::INSTANCE_EVENTS_WEBHOOK;
match value {
- Ok(Some(serde_json::Value::String(s))) if !s.is_empty() => {
+ Some(serde_json::Value::String(s)) if !s.is_empty() => {
INSTANCE_EVENTS_WEBHOOK.store(std::sync::Arc::new(Some(s)));
}
- Ok(None) | Ok(Some(serde_json::Value::Null)) | Ok(Some(serde_json::Value::String(_))) => {
+ None | Some(serde_json::Value::Null) | Some(serde_json::Value::String(_)) => {
// Fall back to env var if DB has no value
INSTANCE_EVENTS_WEBHOOK.store(std::sync::Arc::new(
std::env::var("INSTANCE_EVENTS_WEBHOOK").ok(),
));
}
- Err(e) => {
- tracing::error!("Error loading instance_events_webhook setting: {e:#}");
- }
_ => (),
};
}
@@ -2238,15 +2499,6 @@ pub async fn reload_timeout_wait_result_setting(conn: &Connection) {
.await;
}
-pub async fn reload_saml_metadata_setting(conn: &Connection) {
- reload_option_setting_with_tracing(
- conn,
- SAML_METADATA_SETTING,
- "SAML_METADATA",
- SAML_METADATA.clone(),
- )
- .await;
-}
pub async fn reload_extra_pip_index_url_setting(conn: &Connection) {
reload_option_setting_with_tracing(
@@ -2338,9 +2590,6 @@ pub async fn reload_bunfig_install_scopes_setting(conn: &Connection) {
.await;
}
-pub async fn reload_npmrc_setting(conn: &Connection) {
- reload_option_setting_with_tracing(conn, NPMRC_SETTING, "NPMRC", NPMRC.clone()).await;
-}
pub async fn reload_nuget_config_setting(conn: &Connection) {
reload_option_setting_with_tracing(
@@ -2390,7 +2639,12 @@ pub async fn reload_maven_settings_xml_setting(conn: &Connection) {
MAVEN_SETTINGS_XML.clone(),
)
.await;
+ write_maven_settings_xml().await;
+}
+/// The half of [`reload_maven_settings_xml_setting`] after the read: mirrors the loaded value
+/// onto disk, where the Maven CLI looks for it.
+pub async fn write_maven_settings_xml() {
if !cfg!(feature = "enterprise") {
return;
}
@@ -2416,21 +2670,24 @@ pub async fn reload_maven_settings_xml_setting(conn: &Connection) {
}
pub async fn reload_no_default_maven_setting(conn: &Connection) {
- let value = load_value_from_global_settings_with_conn(
+ match load_value_from_global_settings_with_conn(
conn,
windmill_common::global_settings::NO_DEFAULT_MAVEN_SETTING,
true,
)
- .await;
- match value {
- Ok(Some(serde_json::Value::Bool(t))) => NO_DEFAULT_MAVEN.store(t, Ordering::Relaxed),
- Err(e) => {
- tracing::error!("Error loading no default maven repository: {e:#}");
- }
- _ => (),
+ .await
+ {
+ Ok(v) => apply_no_default_maven_setting(v),
+ Err(e) => tracing::error!("Error loading no default maven repository: {e:#}"),
};
}
+pub fn apply_no_default_maven_setting(value: Option) {
+ if let Some(serde_json::Value::Bool(t)) = value {
+ NO_DEFAULT_MAVEN.store(t, Ordering::Relaxed)
+ }
+}
+
pub async fn reload_ruby_repos_setting(conn: &Connection) {
reload_url_list_setting_with_tracing(
conn,
@@ -2441,25 +2698,23 @@ pub async fn reload_ruby_repos_setting(conn: &Connection) {
.await;
}
-pub async fn reload_cargo_registries_setting(conn: &Connection) {
- reload_option_setting_with_tracing(
- conn,
- windmill_common::global_settings::CARGO_REGISTRIES_SETTING,
- "CARGO_REGISTRIES",
- CARGO_REGISTRIES.clone(),
- )
- .await;
-}
pub async fn reload_workspace_registries_setting(conn: &Connection) {
- let value = load_value_from_global_settings_with_conn(
+ match load_value_from_global_settings_with_conn(
conn,
windmill_common::global_settings::WORKSPACE_REGISTRIES_SETTING,
true,
)
- .await;
+ .await
+ {
+ Ok(v) => apply_workspace_registries_setting(v).await,
+ Err(e) => tracing::error!("Error loading workspace_registries setting: {e:#}"),
+ }
+}
+
+pub async fn apply_workspace_registries_setting(value: Option) {
match value {
- Ok(Some(v)) => match serde_json::from_value::(v) {
+ Some(v) => match serde_json::from_value::(v) {
Ok(parsed) => {
tracing::info!(
"Loaded workspace registries for {} workspaces",
@@ -2471,12 +2726,9 @@ pub async fn reload_workspace_registries_setting(conn: &Connection) {
tracing::error!("Error parsing workspace_registries setting: {e:#}");
}
},
- Ok(None) => {
+ None => {
*WORKSPACE_REGISTRIES.write().await = None;
}
- Err(e) => {
- tracing::error!("Error loading workspace_registries setting: {e:#}");
- }
}
}
@@ -2622,16 +2874,14 @@ pub async fn reload_sandbox_registry_auth_setting(conn: &Connection) {
// Secret-aware: the value is a raw docker/podman auth.json with credentials, so
// it must never be logged. Load directly (the generic reload_option_setting path
// logs the value via load_option_setting_value) and only log a redacted message.
- let q =
- match load_value_from_global_settings_with_conn(conn, SANDBOX_REGISTRY_AUTH_SETTING, true)
- .await
- {
- Ok(q) => q,
- Err(e) => {
- tracing::error!("Error reloading setting SANDBOX_REGISTRY_AUTH: {e:?}");
- return;
- }
- };
+ match load_value_from_global_settings_with_conn(conn, SANDBOX_REGISTRY_AUTH_SETTING, true).await
+ {
+ Ok(q) => apply_sandbox_registry_auth_setting(q).await,
+ Err(e) => tracing::error!("Error reloading setting SANDBOX_REGISTRY_AUTH: {e:?}"),
+ }
+}
+
+pub async fn apply_sandbox_registry_auth_setting(q: Option) {
let value = q.and_then(|q| serde_json::from_value::(q).ok());
let configured = value.as_ref().is_some_and(|v| !v.trim().is_empty());
*SANDBOX_REGISTRY_AUTH.write().await = value;
@@ -2639,15 +2889,17 @@ pub async fn reload_sandbox_registry_auth_setting(conn: &Connection) {
}
pub async fn reload_job_isolation_setting(conn: &Connection) {
- let value =
- match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await {
- Ok(Some(v)) => JobIsolationLevel::from_str(v.as_str().unwrap_or("")),
- Ok(None) => JobIsolationLevel::Undefined,
- Err(e) => {
- tracing::error!("Error reloading job_isolation setting: {:?}", e);
- return;
- }
- };
+ match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await {
+ Ok(v) => apply_job_isolation_setting(v).await,
+ Err(e) => tracing::error!("Error reloading job_isolation setting: {:?}", e),
+ }
+}
+
+pub async fn apply_job_isolation_setting(value: Option) {
+ let value = match value {
+ Some(v) => JobIsolationLevel::from_str(v.as_str().unwrap_or("")),
+ None => JobIsolationLevel::Undefined,
+ };
let old_value = JobIsolationLevel::from_u8(JOB_ISOLATION.swap(value as u8, Ordering::Relaxed));
if old_value != value {
tracing::info!(
@@ -2670,20 +2922,6 @@ pub async fn reload_job_isolation_setting(conn: &Connection) {
}
}
-pub async fn reload_request_size(conn: &Connection) {
- if let Err(e) = reload_setting(
- conn,
- REQUEST_SIZE_LIMIT_SETTING,
- "REQUEST_SIZE_LIMIT",
- DEFAULT_BODY_LIMIT,
- REQUEST_SIZE_LIMIT.clone(),
- |x| x.mul(1024 * 1024),
- )
- .await
- {
- tracing::error!("Error reloading retention period: {:?}", e)
- }
-}
async fn resolve_license_key_value(conn: &Connection, quiet: bool) -> anyhow::Result {
let q = load_value_from_global_settings_with_conn(conn, LICENSE_KEY_SETTING, true)
@@ -2792,18 +3030,321 @@ pub async fn reload_option_setting_with_tracing(
}
}
-pub async fn load_value_from_global_settings(
- db: &DB,
- setting_name: &str,
-) -> error::Result> {
- let r = sqlx::query!(
- "SELECT value FROM global_settings WHERE name = $1",
- setting_name
- )
- .fetch_optional(db)
- .await?
- .map(|x| x.value);
- Ok(r)
+type SettingApplier<'a> =
+ Box) -> BoxFuture<'a, ()> + Send + 'a>;
+
+/// The values a [`PassStep::Settings`] step asked for, keyed by setting name.
+pub type SettingValues = std::collections::HashMap<&'static str, Option>;
+
+type MultiSettingApplier<'a> = Box BoxFuture<'a, ()> + Send + 'a>;
+
+enum PassStep<'a> {
+ /// A setting to read, paired with what to do once its value is in hand.
+ Setting { name: &'static str, http: bool, apply: SettingApplier<'a> },
+ /// Several settings one piece of work needs together.
+ Settings { names: &'static [&'static str], http: bool, apply: MultiSettingApplier<'a> },
+ /// Work that is not a settings read but has to keep its place in the sequence.
+ Action(BoxFuture<'a, ()>),
+}
+
+/// One settings-loading pass: every read it will make, declared up front, then fetched
+/// together and applied in the order they were declared.
+///
+/// A pass that reads several dozen settings costs one round trip instead of one each, which
+/// is invisible against a local database and is the bulk of a worker's startup latency
+/// against a real one. Declaring is what makes the batch exact: the same `if server_mode` /
+/// `if *CLOUD_HOSTED` branches that used to guard a read now guard a [`Self::setting`] call,
+/// so the fetch asks for what this process actually needs and nothing else.
+///
+/// Order is preserved end to end, which is what lets non-setting work sit in the middle of the
+/// sequence via [`Self::action`]: appliers run in declaration order, so a setting whose applier
+/// depends on an earlier one having landed still sees it.
+pub struct SettingsPass<'a> {
+ steps: Vec>,
+}
+
+impl<'a> SettingsPass<'a> {
+ pub fn new() -> Self {
+ SettingsPass { steps: Vec::new() }
+ }
+
+ /// Declare a read. `http` mirrors `load_from_http` in
+ /// [`load_value_from_global_settings_with_conn`]: `false` means an agent worker leaves the
+ /// setting unset rather than asking the server for it.
+ pub fn setting(&mut self, name: &'static str, http: bool, apply: F)
+ where
+ F: FnOnce(Option) -> Fut + Send + 'a,
+ Fut: std::future::Future + Send + 'a,
+ {
+ self.steps.push(PassStep::Setting {
+ name,
+ http,
+ apply: Box::new(move |v| Box::pin(apply(v))),
+ });
+ }
+
+ /// Declare a step that needs several settings at once. It is skipped, like a single
+ /// [`Self::setting`], if any of them could not be read.
+ pub fn settings(&mut self, names: &'static [&'static str], http: bool, apply: F)
+ where
+ F: FnOnce(SettingValues) -> Fut + Send + 'a,
+ Fut: std::future::Future + Send + 'a,
+ {
+ self.steps.push(PassStep::Settings {
+ names,
+ http,
+ apply: Box::new(move |v| Box::pin(apply(v))),
+ });
+ }
+
+ /// Declare work that is not a settings read, keeping its position in the sequence.
+ pub fn action(&mut self, fut: Fut)
+ where
+ Fut: std::future::Future + Send + 'a,
+ {
+ self.steps.push(PassStep::Action(Box::pin(fut)));
+ }
+
+ /// The batched form of [`reload_option_setting_with_tracing`].
+ pub fn option_setting(
+ &mut self,
+ name: &'static str,
+ std_env_var: &'static str,
+ lock: Arc>>,
+ ) {
+ self.option_setting_with(name, std_env_var, move |v| async move {
+ *lock.write().await = v;
+ });
+ }
+
+ /// [`Self::option_setting`] for a setting held in something other than an
+ /// `Arc>>`, such as an `ArcSwap`. Going through here rather than calling
+ /// [`parse_option_setting_value`] from a bare [`Self::setting`] is what applies the
+ /// `FORCE_` rule, which a hand-rolled declaration would silently miss.
+ pub fn option_setting_with(
+ &mut self,
+ name: &'static str,
+ std_env_var: &'static str,
+ store: F,
+ ) where
+ T: FromStr + DeserializeOwned + Send + Sync + 'a,
+ F: FnOnce(Option) -> Fut + Send + 'a,
+ Fut: std::future::Future + Send + 'a,
+ {
+ if forced_env_value::(std_env_var).is_some() {
+ self.forced(move || async move {
+ store(parse_option_setting_value(None, name, std_env_var)).await
+ });
+ return;
+ }
+ self.setting(name, true, move |v| async move {
+ store(parse_option_setting_value(v, name, std_env_var)).await
+ });
+ }
+
+ /// A setting with a default, the batched counterpart of [`load_setting_value`].
+ pub fn required_setting(
+ &mut self,
+ name: &'static str,
+ std_env_var: &'static str,
+ default: T,
+ lock: Arc>,
+ transformer: fn(T) -> T,
+ ) {
+ self.setting(name, true, move |v| async move {
+ *lock.write().await = parse_setting_value(v, name, std_env_var, default, transformer);
+ });
+ }
+
+ /// The batched form of [`reload_url_list_setting_with_tracing`].
+ pub fn url_list_setting(
+ &mut self,
+ name: &'static str,
+ std_env_var: &'static str,
+ lock: Arc>>>,
+ ) {
+ if std::env::var(format!("FORCE_{}", std_env_var)).is_ok() {
+ self.forced(move || async move {
+ *lock.write().await = parse_url_list_setting_value(None, name, std_env_var);
+ });
+ return;
+ }
+ self.setting(name, true, move |v| async move {
+ *lock.write().await = parse_url_list_setting_value(v, name, std_env_var);
+ });
+ }
+
+ /// Apply a setting whose value comes from a `FORCE_` override.
+ ///
+ /// Declared as a step with no read: the override outranks the database, so fetching is
+ /// pointless, and more importantly a failed fetch must not drop it. A skipped applier is
+ /// how an unreadable setting keeps its current value, which for a forced one would mean
+ /// silently running unforced.
+ fn forced(&mut self, apply: F)
+ where
+ F: FnOnce() -> Fut + Send + 'a,
+ Fut: std::future::Future + Send + 'a,
+ {
+ self.action(async move { apply().await });
+ }
+
+ /// Fetch every declared setting in one go, then run the steps in declaration order.
+ pub async fn run(self, conn: &Connection) {
+ let over_http = matches!(conn, Connection::Http(_));
+ let declared: Vec<(&'static str, bool)> = self
+ .steps
+ .iter()
+ .flat_map(|s| match s {
+ PassStep::Setting { name, http, .. } => vec![(*name, *http)],
+ PassStep::Settings { names, http, .. } => {
+ names.iter().map(|n| (*n, *http)).collect()
+ }
+ PassStep::Action(_) => vec![],
+ })
+ .collect();
+
+ // A setting an agent worker is not allowed to ask the server for reads as unset, which
+ // is what `load_value_from_global_settings_with_conn(.., false)` returned for it. That
+ // is not the same as a read that failed, which is left out and skips its applier.
+ let names: Vec<&str> = declared
+ .iter()
+ .filter_map(|(name, http)| (!over_http || *http).then_some(*name))
+ .collect();
+
+ let mut values = fetch_settings_batch(conn, &names).await;
+ // One failed query took every setting with it. At startup there is no known-good
+ // in-memory state to preserve, so read them individually rather than leave the process
+ // on compile-time defaults until the next full reload. Only the single-query transport
+ // can fail this way; over HTTP the batch already is the per-setting read.
+ if matches!(conn, Connection::Sql(_)) && values.is_empty() && !names.is_empty() {
+ tracing::warn!("Falling back to per-setting reads for {} settings", names.len());
+ values = fetch_settings_individually(conn, &names).await;
+ }
+ for (name, http) in &declared {
+ if over_http && !*http {
+ values.insert(name.to_string(), None);
+ }
+ }
+
+ for step in self.steps {
+ match step {
+ // A name missing from `values` is one whose read failed, not one that is
+ // unset. Skipping its applier is what keeps a transient database error from
+ // looking like a cleared setting: several of them reset to a default on
+ // `None`, and would otherwise clobber a known-good value on a blip.
+ PassStep::Setting { name, apply, .. } => {
+ if let Some(value) = values.remove(name) {
+ apply(value).await
+ } else {
+ tracing::warn!(
+ "Setting {name} could not be read, leaving its in-memory value unchanged"
+ );
+ }
+ }
+ PassStep::Settings { names, apply, .. } => {
+ let asked: SettingValues = names
+ .iter()
+ .filter_map(|n| values.get(*n).map(|v| (*n, v.clone())))
+ .collect();
+ if asked.len() == names.len() {
+ apply(asked).await
+ } else {
+ let missing = names
+ .iter()
+ .filter(|n| !asked.contains_key(*n))
+ .copied()
+ .collect::>()
+ .join(", ");
+ tracing::warn!(
+ "Settings {missing} could not be read, leaving the in-memory values of {} unchanged",
+ names.join(", ")
+ );
+ }
+ }
+ PassStep::Action(fut) => fut.await,
+ }
+ }
+ }
+}
+
+/// The value of a `FORCE_` override, when it is set and parses.
+///
+/// Mirrors the check [`load_option_setting_value`] makes before it reads, so the batched and
+/// per-setting paths agree on when the database is consulted at all.
+fn forced_env_value(std_env_var: &str) -> Option {
+ std::env::var(format!("FORCE_{}", std_env_var))
+ .ok()
+ .and_then(|x| x.parse::().ok())
+}
+
+/// Parse whitespace-separated URLs, dropping and reporting the ones that do not parse.
+fn parse_url_list(raw: &str, source: &str) -> Vec {
+ raw.trim()
+ .split_whitespace()
+ .filter_map(|url_str| match url::Url::parse(url_str) {
+ Ok(url) => Some(url),
+ Err(e) => {
+ tracing::error!("Invalid URL in {}: '{}': {}", source, url_str, e);
+ None
+ }
+ })
+ .collect()
+}
+
+/// One read per setting, concurrently. The fallback for a batch that failed as a whole: a
+/// name whose own read also fails is left out, so its applier is skipped rather than told the
+/// setting is unset.
+async fn fetch_settings_individually(
+ conn: &Connection,
+ names: &[&str],
+) -> std::collections::HashMap> {
+ futures::future::join_all(names.iter().map(|name| async move {
+ (
+ name.to_string(),
+ load_value_from_global_settings_with_conn(conn, name, true).await,
+ )
+ }))
+ .await
+ .into_iter()
+ .filter_map(|(name, value)| match value {
+ Ok(value) => Some((name, value)),
+ Err(e) => {
+ tracing::error!("Error loading setting {name}: {e:#}");
+ None
+ }
+ })
+ .collect()
+}
+
+/// Read many settings at once: one query over a database connection, and for an agent worker
+/// one concurrent round of the per-setting endpoint rather than a sequential walk of it.
+///
+/// A name whose read failed is left out entirely, which the caller distinguishes from a name
+/// that is present with no value, i.e. genuinely unset.
+async fn fetch_settings_batch(
+ conn: &Connection,
+ names: &[&str],
+) -> std::collections::HashMap> {
+ match conn {
+ Connection::Sql(db) => {
+ match windmill_common::global_settings::load_values_from_global_settings(db, names)
+ .await
+ {
+ // Every requested name is accounted for: the ones with no row read as unset.
+ Ok(mut rows) => names
+ .iter()
+ .map(|name| (name.to_string(), rows.remove(*name)))
+ .collect(),
+ Err(e) => {
+ tracing::error!("Could not load global settings: {e:#}");
+ std::collections::HashMap::new()
+ }
+ }
+ }
+ // An agent worker has no batch endpoint, but issuing the reads together still costs
+ // one round instead of one per setting.
+ Connection::Http(_) => fetch_settings_individually(conn, names).await,
+ }
}
pub async fn load_value_from_global_settings_with_conn(
@@ -2848,6 +3389,22 @@ pub async fn load_option_setting_value(
}
let q = load_value_from_global_settings_with_conn(conn, setting_name, true).await?;
+ Ok(parse_option_setting_value(q, setting_name, std_env_var))
+}
+
+/// The half of [`load_option_setting_value`] after the read, so [`SettingsPass`] can parse a
+/// value it already fetched.
+pub fn parse_option_setting_value(
+ q: Option,
+ setting_name: &str,
+ std_env_var: &str,
+) -> Option {
+ if let Some(force_value) = std::env::var(format!("FORCE_{}", std_env_var))
+ .ok()
+ .and_then(|x| x.parse::().ok())
+ {
+ return Some(force_value);
+ }
let mut value = std::env::var(std_env_var)
.ok()
@@ -2866,7 +3423,7 @@ pub async fn load_option_setting_value(
tracing::info!("Loaded {setting_name} setting to None");
}
- Ok(value)
+ value
}
pub async fn reload_option_setting(
@@ -2922,6 +3479,23 @@ pub async fn load_url_list_setting_value(
}
let q = load_value_from_global_settings_with_conn(conn, setting_name, true).await?;
+ Ok(parse_url_list_setting_value(q, setting_name, std_env_var))
+}
+
+/// The half of [`load_url_list_setting_value`] after the read, so [`SettingsPass`] can parse a
+/// value it already fetched.
+pub fn parse_url_list_setting_value(
+ q: Option,
+ setting_name: &str,
+ std_env_var: &str,
+) -> Option> {
+ // A FORCE_ override wins over both the database and the ordinary env var. Invalid URLs in
+ // it are dropped with an error here rather than failing the read, since a settings pass
+ // has nowhere to return the failure to.
+ if let Ok(force_value) = std::env::var(format!("FORCE_{}", std_env_var)) {
+ let urls = parse_url_list(&force_value, &format!("FORCE_{}", std_env_var));
+ return if urls.is_empty() { None } else { Some(urls) };
+ }
// Check regular environment variable
let mut value = if let Ok(env_value) = std::env::var(std_env_var) {
@@ -2972,7 +3546,7 @@ pub async fn load_url_list_setting_value(
tracing::info!("Loaded {} setting to None", setting_name);
}
- Ok(value)
+ value
}
pub async fn reload_url_list_setting(
@@ -2989,11 +3563,9 @@ pub async fn reload_url_list_setting(
Ok(())
}
-/// Load a required setting value without writing it anywhere.
-///
-/// Extracted from [`reload_setting`] so callers that store the value in
-/// something other than `Arc>` (e.g. `AtomicI64`, `AtomicBool`,
-/// `ArcSwap`) can reuse the load pipeline.
+/// Load a required setting value without writing it anywhere, so callers that store it in
+/// something other than `Arc>` (e.g. `AtomicI64`, `AtomicBool`, `ArcSwap`) can
+/// reuse the load pipeline.
pub async fn load_setting_value(
conn: &Connection,
setting_name: &str,
@@ -3002,7 +3574,24 @@ pub async fn load_setting_value(
transformer: fn(T) -> T,
) -> error::Result {
let q = load_value_from_global_settings_with_conn(conn, setting_name, true).await?;
+ Ok(parse_setting_value(
+ q,
+ setting_name,
+ std_env_var,
+ default,
+ transformer,
+ ))
+}
+/// The half of [`load_setting_value`] after the read, so [`SettingsPass`] can parse a value it
+/// already fetched.
+pub fn parse_setting_value(
+ q: Option,
+ setting_name: &str,
+ std_env_var: &str,
+ default: T,
+ transformer: fn(T) -> T,
+) -> T {
let mut value = std::env::var(std_env_var)
.ok()
.and_then(|x| x.parse::().ok())
@@ -3017,24 +3606,9 @@ pub async fn load_setting_value(
}
};
- Ok(value)
+ value
}
-pub async fn reload_setting(
- conn: &Connection,
- setting_name: &str,
- std_env_var: &str,
- default: T,
- lock: Arc>,
- transformer: fn(T) -> T,
-) -> error::Result<()> {
- let value = load_setting_value(conn, setting_name, std_env_var, default, transformer).await?;
- {
- let mut l = lock.write().await;
- *l = value;
- }
- Ok(())
-}
#[cfg(feature = "prometheus")]
pub async fn monitor_pool(db: &DB) {
@@ -3242,6 +3816,19 @@ pub async fn monitor_db(
}
};
+ // Not gated on server_mode: feature-usage counters accumulate wherever an
+ // instrumented call site runs, and a worker that never flushed would lose
+ // its counts on shutdown.
+ let feature_usage_f = async {
+ if !initial_load {
+ if let Some(db) = conn.as_sql() {
+ if let Err(e) = windmill_common::feature_usage::flush_feature_usage(db).await {
+ tracing::error!("Error flushing feature_usage counters: {e}");
+ }
+ }
+ }
+ };
+
let verify_license_key_f = async {
#[cfg(feature = "enterprise")]
if !initial_load {
@@ -3491,6 +4078,7 @@ pub async fn monitor_db(
join!(
expired_items_f,
+ feature_usage_f,
zombie_jobs_f,
stale_jobs_f,
trim_resource_versions_f,
@@ -4328,7 +4916,12 @@ pub async fn reload_worker_config(db: &DB, tx: KillpillSender, kill_if_change: b
pub async fn load_base_url(conn: &Connection) -> error::Result {
let q_base_url =
load_value_from_global_settings_with_conn(conn, BASE_URL_SETTING, false).await?;
+ Ok(parse_base_url(q_base_url))
+}
+/// The half of [`load_base_url`] after the read, so [`SettingsPass`] can use a value it
+/// already fetched. Stores into `BASE_URL` as well as returning it.
+pub fn parse_base_url(q_base_url: Option) -> String {
let std_base_url = std::env::var("BASE_URL")
.ok()
.unwrap_or_else(|| "http://localhost".to_string());
@@ -4350,14 +4943,32 @@ pub async fn load_base_url(conn: &Connection) -> error::Result {
std_base_url
};
BASE_URL.store(std::sync::Arc::new(base_url.clone()));
- Ok(base_url)
+ base_url
}
pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> {
#[cfg(feature = "oauth2")]
- let oauths = if let Some(db) = conn.as_sql() {
- let q_oauth = load_value_from_global_settings(db, OAUTH_SETTING).await?;
+ let q_oauth = match conn.as_sql() {
+ Some(db) => load_value_from_global_settings(db, OAUTH_SETTING).await?,
+ None => None,
+ };
+ #[cfg(not(feature = "oauth2"))]
+ let q_oauth = None;
+ let q_base_url =
+ load_value_from_global_settings_with_conn(conn, BASE_URL_SETTING, false).await?;
+ apply_base_url_setting(conn, q_oauth, q_base_url).await
+}
+/// The half of [`reload_base_url_setting`] after the reads.
+pub async fn apply_base_url_setting(
+ conn: &Connection,
+ q_oauth: Option,
+ q_base_url: Option,
+) -> error::Result<()> {
+ // Both only reach a use under a feature gate.
+ let (_, _) = (&conn, &q_oauth);
+ #[cfg(feature = "oauth2")]
+ let oauths = if conn.as_sql().is_some() {
if let Some(q) = q_oauth {
if let Ok(v) = serde_json::from_value::<
Option>,
@@ -4374,7 +4985,7 @@ pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> {
} else {
None
};
- let base_url = load_base_url(conn).await?;
+ let base_url = parse_base_url(q_base_url);
let is_secure = base_url.starts_with("https://");
#[cfg(feature = "oauth2")]
@@ -5540,9 +6151,18 @@ pub async fn reload_hub_base_url_setting(
conn: &Connection,
server_mode: bool,
) -> error::Result<()> {
- let hub_base_url =
- load_value_from_global_settings_with_conn(conn, HUB_BASE_URL_SETTING, true).await?;
+ let v = load_value_from_global_settings_with_conn(conn, HUB_BASE_URL_SETTING, true).await?;
+ apply_hub_base_url_setting(conn, server_mode, v).await
+}
+/// The half of [`reload_hub_base_url_setting`] after the read.
+pub async fn apply_hub_base_url_setting(
+ conn: &Connection,
+ server_mode: bool,
+ hub_base_url: Option,
+) -> error::Result<()> {
+ // Only reaches a use under the `embedding` feature.
+ let _ = &conn;
let base_url = if let Some(q) = hub_base_url {
if let Ok(v) = serde_json::from_value::(q.clone()) {
if v != "" {
@@ -5586,9 +6206,12 @@ pub async fn reload_hub_base_url_setting(
}
pub async fn reload_critical_error_channels_setting(conn: &DB) -> error::Result<()> {
- let critical_error_channels =
- load_value_from_global_settings(conn, CRITICAL_ERROR_CHANNELS_SETTING).await?;
+ let v = load_value_from_global_settings(conn, CRITICAL_ERROR_CHANNELS_SETTING).await?;
+ apply_critical_error_channels_setting(v);
+ Ok(())
+}
+pub fn apply_critical_error_channels_setting(critical_error_channels: Option) {
let critical_error_channels = if let Some(q) = critical_error_channels {
if let Ok(v) = serde_json::from_value::>(q.clone()) {
v
@@ -5604,14 +6227,15 @@ pub async fn reload_critical_error_channels_setting(conn: &DB) -> error::Result<
};
CRITICAL_ERROR_CHANNELS.store(std::sync::Arc::new(critical_error_channels));
-
- Ok(())
}
pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()> {
- let app_workspaced_route =
- load_value_from_global_settings(conn, APP_WORKSPACED_ROUTE_SETTING).await?;
+ let v = load_value_from_global_settings(conn, APP_WORKSPACED_ROUTE_SETTING).await?;
+ apply_app_workspaced_route_setting(v);
+ Ok(())
+}
+pub fn apply_app_workspaced_route_setting(app_workspaced_route: Option) {
let ws_route = match app_workspaced_route {
Some(serde_json::Value::Bool(ws_route)) => ws_route,
None => false,
@@ -5626,13 +6250,17 @@ pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()>
};
APP_WORKSPACED_ROUTE.store(ws_route, Ordering::Relaxed);
- Ok(())
}
pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> {
- let http_route_workspaced_route =
- load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?;
+ let v = load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?;
+ apply_http_route_workspaced_route_setting(conn, v).await
+}
+pub async fn apply_http_route_workspaced_route_setting(
+ conn: &DB,
+ http_route_workspaced_route: Option,
+) -> error::Result<()> {
let ws_route = match http_route_workspaced_route {
Some(serde_json::Value::Bool(ws_route)) => ws_route,
None => false,
@@ -5689,30 +6317,34 @@ pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<(
Ok(())
}
-async fn generate_and_save_jwt_secret(db: &DB) -> error::Result {
- let secret = rd_string(32);
- sqlx::query!(
- "INSERT INTO global_settings (name, value) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
- JWT_SECRET_SETTING,
- serde_json::to_value(&secret).unwrap()
- ).execute(db).await?;
-
- Ok(secret)
-}
pub async fn reload_jwt_secret_setting(db: &DB) -> error::Result<()> {
- let jwt_secret = load_value_from_global_settings(db, JWT_SECRET_SETTING).await?;
+ let v = load_value_from_global_settings(db, JWT_SECRET_SETTING).await?;
+ apply_jwt_secret_setting(db, v).await
+}
- let jwt_secret = if let Some(q) = jwt_secret {
- if let Ok(v) = serde_json::from_value::(q.clone()) {
- v
- } else {
- tracing::error!("Could not parse jwt_secret setting, generating new one");
- generate_and_save_jwt_secret(db).await?
+/// The half of [`reload_jwt_secret_setting`] after the read.
+///
+/// `value` may be stale, which is why generating falls to
+/// [`get_or_create_jwt_secret`]: that statement, not this read, decides whether a new secret
+/// is stored, so a pass that batched an absent read cannot overwrite one another process
+/// wrote in the meantime.
+pub async fn apply_jwt_secret_setting(
+ db: &DB,
+ value: Option,
+) -> error::Result<()> {
+ let jwt_secret = match value {
+ Some(q) => match serde_json::from_value::(q) {
+ Ok(v) => v,
+ Err(_) => {
+ tracing::error!("Could not parse jwt_secret setting, generating new one");
+ get_or_create_jwt_secret(db).await?
+ }
+ },
+ None => {
+ tracing::info!("No jwt secret found, generating one");
+ get_or_create_jwt_secret(db).await?
}
- } else {
- tracing::info!("Not jwt secret found, generating one");
- generate_and_save_jwt_secret(db).await?
};
JWT_SECRET.store(std::sync::Arc::new(jwt_secret));
diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt
index 44b62ca51e..732f1ece52 100644
--- a/backend/summarized_schema.txt
+++ b/backend/summarized_schema.txt
@@ -176,6 +176,9 @@ token: token_hash(char), token_prefix(char), token(char), label(char), expiratio
FK: (workspace_id) -> workspace(id)
token_expiry_notification: token_hash(char), expiration(ts)
INDEX: idx_token_expiry_notification_expiration (expiration)
+trigger_history: id(bigint), workspace_id(char), trigger_kind(char), path(char), operation(char), source(char), username(char), created_at(ts), changes(jsonb)
+ FK: (workspace_id) -> workspace(id)
+ INDEX: idx_trigger_history_workspace_kind_path (workspace_id, trigger_kind, path, id), idx_trigger_history_workspace_id (workspace_id, id)
tutorial_progress: email(char), progress(bit64), skipped_all(bool)
unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts), email(text), username(text), is_admin(bool), is_operator(bool), workspace_id(text?), label(text?), scopes(text[]?)
usage: id(char), is_workspace(bool), month_(int), usage(int)
diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql
index 03ee69b1f4..d7d11228aa 100644
--- a/backend/tests/fixtures/jobs_read_auth.sql
+++ b/backend/tests/fixtures/jobs_read_auth.sql
@@ -19,6 +19,76 @@ INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, sc
ARRAY['jobs:read', 'if_jobs:filter_tags:deno']
);
+-- A path-scoped run token for test-user-2, as the trigger UI mints per runnable for a
+-- webhook caller. test-user-2 created every job this token is asserted against, so the
+-- `created_by` grant would otherwise hand it all of them; it must reach only jobs of
+-- `f/shared/flow1`.
+INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
+ encode(sha256('RUN_SCOPED_TOKEN'::bytea), 'hex'), 'RUN_SCOPE', 'RUN_SCOPED_TOKEN',
+ 'test2@windmill.dev', 'flow webhook token', false,
+ ARRAY['jobs:run:flows:f/shared/flow1']
+);
+
+-- Same, scoped to a script. The two jobs below both run through a `singlestepflow`
+-- wrapper (native retry / scheduled runs produce these) — one wrapping a script, one
+-- wrapping a flow — so the confinement has to project each onto the runnable it wraps
+-- rather than onto the wrapper's own `kind`.
+INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
+ encode(sha256('RUN_SCOPED_SCRIPT_TOKEN'::bytea), 'hex'), 'RUN_SCRIP', 'RUN_SCOPED_SCRIPT_TOKEN',
+ 'test2@windmill.dev', 'script webhook token', false,
+ ARRAY['jobs:run:scripts:u/test-user-2/wrapped_script']
+);
+
+INSERT INTO public.v2_job (
+ id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
+ kind, script_lang, runnable_path, tag, visible_to_owner, raw_flow
+) VALUES (
+ '14141414-1414-1414-1414-141414141414', 'test-workspace', 'test-user-2',
+ '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
+ 'singlestepflow', 'deno', 'u/test-user-2/wrapped_script', 'deno', true,
+ '{"modules": [{"id": "a", "value": {"type": "script", "path": "u/test-user-2/wrapped_script"}}]}'
+);
+INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
+ ('14141414-1414-1414-1414-141414141414', 'test-workspace', 1000, 'success'::job_status,
+ '{"wrapped": "WRAPPED_RESULT"}');
+
+INSERT INTO public.v2_job (
+ id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
+ kind, script_lang, runnable_path, tag, visible_to_owner, raw_flow
+) VALUES (
+ '15151515-1515-1515-1515-151515151515', 'test-workspace', 'test-user-2',
+ '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
+ 'singlestepflow', 'deno', 'f/shared/flow1', 'flow', true,
+ '{"modules": [{"id": "a", "value": {"type": "flow", "path": "f/shared/flow1"}}]}'
+);
+INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
+ ('15151515-1515-1515-1515-151515151515', 'test-workspace', 1000, 'success'::job_status,
+ '{"wrapped": "WRAPPED_FLOW_RESULT"}');
+
+-- A token pairing an app scope with a run scope, as someone driving an app's components
+-- programmatically would build. `APP_INLINE_JOB` is an inline-script component run: no
+-- `jobs:run` scope can name its kind, so only the `apps:run` half puts it in reach.
+INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
+ encode(sha256('APP_RUNNER_TOKEN'::bytea), 'hex'), 'APP_RUNNE', 'APP_RUNNER_TOKEN',
+ 'test2@windmill.dev', 'app runner token', false,
+ ARRAY['apps:run:u/test-user-2/dash', 'jobs:run:scripts:u/test-user-2/wrapped_script']
+);
+
+-- An inline-script component run of app `u/test-user-2/dash`, stamped with the
+-- app provenance `execute_component` sets (`trigger_kind = 'app'`).
+INSERT INTO public.v2_job (
+ id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
+ kind, script_lang, runnable_path, tag, visible_to_owner, trigger_kind, trigger, args
+) VALUES (
+ '16161616-1616-1616-1616-161616161616', 'test-workspace', 'test-user-2',
+ '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
+ 'appscript', 'deno', NULL, 'deno', false, 'app', 'u/test-user-2/dash',
+ '{"component": "arg"}'
+);
+INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
+ ('16161616-1616-1616-1616-161616161616', 'test-workspace', 1000, 'success'::job_status,
+ '{"inline": "APP_INLINE_RESULT"}');
+
-- App embed token for the admin viewer (test-user). Mirrors a minted sandboxed
-- low-code app token: carries the `app_embed` sentinel plus the embed scope set.
-- Used to assert the token is confined to jobs the viewer LAUNCHED, not every job
diff --git a/backend/tests/fixtures/typechecked_python.sql b/backend/tests/fixtures/typechecked_python.sql
new file mode 100644
index 0000000000..e6350c22f3
--- /dev/null
+++ b/backend/tests/fixtures/typechecked_python.sql
@@ -0,0 +1,20 @@
+INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
+'test-workspace',
+'test-user',
+'
+import inspect
+import sys
+
+def greet(name: str) -> str:
+ # Verify that __file__ is set on this module (same check typeguard does)
+ mod = sys.modules[__name__]
+ source_file = inspect.getfile(mod)
+ return f"Hello, {name}! from {source_file}"
+
+def main():
+ return greet("World")
+',
+'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
+'',
+'',
+'f/system/typechecked_helper', 12349, 'python3', '');
diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs
index 317b1c6866..c8c154c250 100644
--- a/backend/tests/jobs_read_auth.rs
+++ b/backend/tests/jobs_read_auth.rs
@@ -46,6 +46,12 @@ const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";
const EMBED_OWN_JOB: &str = "12121212-1212-1212-1212-121212121212";
// A QUEUED job launched by the embed viewer (created_by test-user) — cancelable by it.
const EMBED_OWN_QUEUED: &str = "13131313-1313-1313-1313-131313131313";
+// `singlestepflow` wrappers (as native retry / scheduled runs produce), one around a
+// SCRIPT and one around a FLOW.
+const WRAPPED_JOB: &str = "14141414-1414-1414-1414-141414141414";
+const WRAPPED_FLOW_JOB: &str = "15151515-1515-1515-1515-151515151515";
+// An inline-script component run of app `u/test-user-2/dash` (`trigger_kind = 'app'`).
+const APP_INLINE_JOB: &str = "16161616-1616-1616-1616-161616161616";
// Queued sub-flow test-user-3 can see (folder `shared`), whose parent top flow they
// cannot. Force cancel walks up to that parent.
const QUEUED_VISIBLE_MID: &str = "55555555-5555-5555-5555-555555555555";
@@ -380,6 +386,160 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul
}
}
+ // ---- PATH-SCOPED RUN TOKEN: confined to jobs of the runnable it may start.
+ // RUN_SCOPED_TOKEN is test-user-2's `jobs:run:flows:f/shared/flow1` webhook
+ // token, and test-user-2 created every job asserted on below — so `created_by`
+ // alone would hand it all of them.
+ // Its own flow run reads, and so do the steps beneath it: a step's `runnable_path`
+ // is the inner script's, so the scope has to be satisfied through the ancestor.
+ for (path, expected) in [
+ (
+ format!("completed/get_result/{FLOW_JOB}"),
+ r#""flow": "done""#,
+ ),
+ (
+ format!("completed/get_result/{STEP_JOB}"),
+ "STEP_RESULT_INHERITED",
+ ),
+ ] {
+ let (status, body) = get(&base, &path, Some("RUN_SCOPED_TOKEN")).await;
+ assert!(
+ status.is_success(),
+ "run-scoped token must read its own flow run ({path}, got {status}): {body}"
+ );
+ assert!(
+ body.contains(expected),
+ "run-scoped token should get {expected} for {path}: {body}"
+ );
+ }
+ // A job of any other runnable is out of scope, even though the same user created it.
+ for path in [
+ format!("completed/get_result/{VICTIM}"),
+ format!("get_args/{VICTIM}"),
+ format!("get_logs/{VICTIM}"),
+ format!("getupdate/{VICTIM}?only_result=true"),
+ ] {
+ let (status, body) = get(&base, &path, Some("RUN_SCOPED_TOKEN")).await;
+ assert_eq!(
+ status,
+ reqwest::StatusCode::NOT_FOUND,
+ "run-scoped token must not read a job outside its scope ({path}, got {status}): {body}"
+ );
+ for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] {
+ assert!(
+ !body.contains(secret),
+ "run-scoped token response for {path} leaked `{secret}`: {body}"
+ );
+ }
+ }
+ // A `singlestepflow` wrapper (native retry / scheduled run) belongs to the runnable
+ // it wraps, not to the flow domain its `kind` suggests. Each wrapper is readable by
+ // the token scoped to the wrapped kind, and only by that one.
+ for (job, reader, denied) in [
+ (WRAPPED_JOB, "RUN_SCOPED_SCRIPT_TOKEN", "RUN_SCOPED_TOKEN"),
+ (
+ WRAPPED_FLOW_JOB,
+ "RUN_SCOPED_TOKEN",
+ "RUN_SCOPED_SCRIPT_TOKEN",
+ ),
+ ] {
+ let (status, body) = get(&base, &format!("completed/get_result/{job}"), Some(reader)).await;
+ assert!(
+ status.is_success() && body.contains("WRAPPED"),
+ "{reader} must read the singlestepflow wrapping its runnable (got {status}): {body}"
+ );
+ let (status, body) = get(&base, &format!("completed/get_result/{job}"), Some(denied)).await;
+ assert_eq!(
+ status,
+ reqwest::StatusCode::NOT_FOUND,
+ "{denied} must not read a wrapper around the other kind (got {status}): {body}"
+ );
+ }
+
+ // An `apps:run:` scope is a start grant too: the inline-script component run it
+ // launched — a kind no `jobs:run` scope can name — stays readable to a token scoped
+ // to that app, and stays out of reach for one that is only scoped to run jobs.
+ let (status, body) = get(
+ &base,
+ &format!("completed/get_result/{APP_INLINE_JOB}"),
+ Some("APP_RUNNER_TOKEN"),
+ )
+ .await;
+ assert!(
+ status.is_success() && body.contains("APP_INLINE_RESULT"),
+ "app-scoped token must read the component run its app launched (got {status}): {body}"
+ );
+ let (status, body) = get(
+ &base,
+ &format!("completed/get_result/{APP_INLINE_JOB}"),
+ Some("RUN_SCOPED_SCRIPT_TOKEN"),
+ )
+ .await;
+ assert_eq!(
+ status,
+ reqwest::StatusCode::NOT_FOUND,
+ "a token with no scope on the app must not read its component run (got {status}): {body}"
+ );
+
+ // An approval link is a bypass of the read gate, so the confinement is re-applied on
+ // top of it: it must not become a way for a scoped token to read an out-of-scope job.
+ // The link itself is untouched — a logged-out approver still reads the same job.
+ let approval_token =
+ windmill_common::variables::generate_approval_token("test-workspace", VICTIM.parse()?, &db)
+ .await?;
+ let (status, body) = get(
+ &base,
+ &format!("get/{VICTIM}?approval_token={approval_token}"),
+ None,
+ )
+ .await;
+ assert!(
+ status.is_success(),
+ "an approval link must still authorize a logged-out read (got {status}): {body}"
+ );
+ let (status, body) = get(
+ &base,
+ &format!("get/{VICTIM}?approval_token={approval_token}"),
+ Some("RUN_SCOPED_TOKEN"),
+ )
+ .await;
+ assert_eq!(
+ status,
+ reqwest::StatusCode::NOT_FOUND,
+ "an approval link must not lift the run-scope confinement (got {status}): {body}"
+ );
+
+ // Same for the resume-secret bypass on the result route, which the approval page uses.
+ let (status, secret) = get(
+ &authed_base,
+ &format!("job_signature/{STEP_JOB}/0"),
+ Some("SECRET_TOKEN_2"),
+ )
+ .await;
+ assert!(status.is_success(), "owner must mint a resume secret: {secret}");
+ let secret = secret.trim().trim_matches('"').to_string();
+ let approval_result =
+ format!("completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}");
+ let (status, body) = get(&base, &approval_result, None).await;
+ assert!(
+ status.is_success(),
+ "a resume secret must still authorize a logged-out result read (got {status}): {body}"
+ );
+ let (status, body) = get(&base, &approval_result, Some("RUN_SCOPED_SCRIPT_TOKEN")).await;
+ assert_eq!(
+ status,
+ reqwest::StatusCode::NOT_FOUND,
+ "a resume secret must not lift the run-scope confinement (got {status}): {body}"
+ );
+
+ // And a run grant is not an enumeration grant: the whole listing surface is denied.
+ let (status, body) = get(&authed_base, "list", Some("RUN_SCOPED_TOKEN")).await;
+ assert_eq!(
+ status,
+ reqwest::StatusCode::FORBIDDEN,
+ "run-scoped token must not enumerate jobs (got {status}): {body}"
+ );
+
// ---- APP EMBED TOKEN: cancellation confined to the app's own jobs. The token
// may cancel a job it launched (created_by == viewer), but `cancel_job_api`
// denies (NotFound) a job created by someone else, even one the (admin)
diff --git a/backend/tests/list_scripts_parent_hash.rs b/backend/tests/list_scripts_parent_hash.rs
new file mode 100644
index 0000000000..bfad39fff8
--- /dev/null
+++ b/backend/tests/list_scripts_parent_hash.rs
@@ -0,0 +1,118 @@
+//! Pins the `parent_hash` filter of `GET /w/{workspace}/scripts/list`: the
+//! request must succeed and return exactly the scripts whose `parent_hashes`
+//! array contains the given hash. The filter is assembled into a raw SQL string,
+//! so a malformed predicate is only caught by executing the query.
+
+use serde_json::json;
+use sqlx::{Pool, Postgres};
+use windmill_test_utils::*;
+
+fn client() -> reqwest::Client {
+ reqwest::Client::new()
+}
+
+fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
+ builder.header("Authorization", format!("Bearer {}", token))
+}
+
+fn new_script(path: &str, content: &str) -> serde_json::Value {
+ json!({
+ "path": path,
+ "summary": "",
+ "description": "",
+ "content": content,
+ "language": "deno",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {},
+ "required": []
+ }
+ })
+}
+
+async fn create(port: u16, script: serde_json::Value) -> anyhow::Result {
+ let resp = authed(
+ client().post(format!(
+ "http://localhost:{port}/api/w/test-workspace/scripts/create"
+ )),
+ "SECRET_TOKEN",
+ )
+ .json(&script)
+ .send()
+ .await?;
+ let status = resp.status();
+ let body = resp.text().await?;
+ assert_eq!(status, 201, "script create should succeed: {body}");
+ Ok(body)
+}
+
+/// Paths returned by `scripts/list` for the given query string.
+async fn list_paths(port: u16, query: &str) -> anyhow::Result> {
+ let resp = authed(
+ client().get(format!(
+ "http://localhost:{port}/api/w/test-workspace/scripts/list?{query}"
+ )),
+ "SECRET_TOKEN",
+ )
+ .send()
+ .await?;
+ let status = resp.status();
+ let body = resp.text().await?;
+ assert_eq!(status, 200, "scripts/list?{query} should succeed: {body}");
+ let items: Vec = serde_json::from_str(&body)?;
+ Ok(items
+ .iter()
+ .map(|it| it["path"].as_str().unwrap().to_string())
+ .collect())
+}
+
+#[sqlx::test(fixtures("base"))]
+async fn test_list_scripts_parent_hash_filter(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+
+ let lineage_path = "u/test-user/parent_hash_lineage";
+ let unrelated_path = "u/test-user/parent_hash_unrelated";
+
+ // A two-version lineage: v2's `parent_hashes` contains v1's hash.
+ let v1 = create(
+ port,
+ new_script(lineage_path, "export async function main() { return 1; }"),
+ )
+ .await?;
+ let mut v2_body = new_script(lineage_path, "export async function main() { return 2; }");
+ v2_body["parent_hash"] = json!(v1);
+ let v2 = create(port, v2_body).await?;
+
+ // ...plus a script with no lineage at all, which must never match.
+ create(
+ port,
+ new_script(unrelated_path, "export async function main() { return 3; }"),
+ )
+ .await?;
+
+ let all = list_paths(port, "").await?;
+ assert!(
+ all.contains(&lineage_path.to_string()) && all.contains(&unrelated_path.to_string()),
+ "unfiltered listing should return both scripts, got {all:?}"
+ );
+
+ let descendants = list_paths(port, &format!("parent_hash={v1}")).await?;
+ assert_eq!(
+ descendants,
+ vec![lineage_path.to_string()],
+ "parent_hash should return only the scripts descending from that hash"
+ );
+
+ // v2 is the head, so nothing descends from it.
+ let none = list_paths(port, &format!("parent_hash={v2}")).await?;
+ assert!(
+ none.is_empty(),
+ "no script descends from the head version, got {none:?}"
+ );
+
+ Ok(())
+}
diff --git a/backend/tests/mcp_token_exfil.rs b/backend/tests/mcp_token_exfil.rs
index ce278f3c53..1d36ba9546 100644
--- a/backend/tests/mcp_token_exfil.rs
+++ b/backend/tests/mcp_token_exfil.rs
@@ -19,6 +19,11 @@
//! and the request only fails later at the connect/SSRF step — proving the
//! legitimate path still resolves the token (no over-blocking).
//!
+//! `POST .../resources/mcp_call_tool/{path}` reaches the same MCP server through
+//! the same resource, so it is pinned to the same property here — both handlers
+//! share `connect_mcp_client`, and a future split of that helper must not let
+//! one of them regress.
+//!
//! SSRF rejection of an author-controlled URL is covered by the unit test in
//! `windmill-mcp` (`from_resource_rejects_ssrf_url`).
#![cfg(feature = "mcp")]
@@ -27,6 +32,7 @@ use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const SECRET_VALUE: &str = "S3CRET-MCP-TOKEN-VALUE";
+const RESOURCE_PATH: &str = "u/test-user-3/evil_mcp";
fn client() -> reqwest::Client {
reqwest::Client::new()
@@ -44,13 +50,28 @@ async fn get(base: &str, path: &str, token: &str) -> (reqwest::StatusCode, Strin
(status, body)
}
-#[sqlx::test(fixtures("base", "mcp_token_exfil"))]
-async fn test_mcp_token_not_exfiltrated(db: Pool) -> anyhow::Result<()> {
- initialize_tracing().await;
+async fn post(
+ base: &str,
+ path: &str,
+ token: &str,
+ body: serde_json::Value,
+) -> (reqwest::StatusCode, String) {
+ let resp = client()
+ .post(format!("{base}/{path}"))
+ .header("Authorization", format!("Bearer {token}"))
+ .json(&body)
+ .send()
+ .await
+ .expect("request");
+ let status = resp.status();
+ let body = resp.text().await.expect("body");
+ (status, body)
+}
- // Insert the locked secret variable with a real, workspace-key-encrypted
- // value so an authorized read genuinely decrypts it.
- let mc = windmill_common::variables::build_crypt(&db, "test-workspace").await?;
+/// Insert the locked secret variable with a real, workspace-key-encrypted value
+/// so an authorized read genuinely decrypts it.
+async fn insert_locked_secret(db: &Pool) -> anyhow::Result<()> {
+ let mc = windmill_common::variables::build_crypt(db, "test-workspace").await?;
let encrypted = windmill_common::variables::encrypt(&mc, SECRET_VALUE);
// Runtime-checked query (not the `query!` macro) so no offline `.sqlx` cache
// entry is needed for this test-only insert.
@@ -59,13 +80,21 @@ async fn test_mcp_token_not_exfiltrated(db: Pool) -> anyhow::Result<()
VALUES ('test-workspace', 'f/locked/secret_token', $1, true, 'Locked secret', '{}')",
)
.bind(&encrypted)
- .execute(&db)
+ .execute(db)
.await?;
+ Ok(())
+}
+
+#[sqlx::test(fixtures("base", "mcp_token_exfil"))]
+async fn test_mcp_token_not_exfiltrated(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+
+ insert_locked_secret(&db).await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/resources/mcp_tools");
- let path = "u/test-user-3/evil_mcp";
+ let path = RESOURCE_PATH;
// ---- CORE REGRESSION: the developer can read the resource but must NOT be
// able to resolve the locked secret. They are denied (401) at the
@@ -109,3 +138,47 @@ async fn test_mcp_token_not_exfiltrated(db: Pool) -> anyhow::Result<()
Ok(())
}
+
+#[sqlx::test(fixtures("base", "mcp_token_exfil"))]
+async fn test_mcp_call_tool_token_not_exfiltrated(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+
+ insert_locked_secret(&db).await?;
+
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let base = format!("http://localhost:{port}/api/w/test-workspace/resources/mcp_call_tool");
+ let body = serde_json::json!({ "tool": "whoami", "arguments": {} });
+
+ let (status, resp) = post(&base, RESOURCE_PATH, "SECRET_TOKEN_3", body.clone()).await;
+ assert_eq!(
+ status,
+ reqwest::StatusCode::UNAUTHORIZED,
+ "developer must be denied resolving a secret they can't read (got {status}): {resp}"
+ );
+ assert!(
+ !resp.contains(SECRET_VALUE),
+ "the locked secret must never leak to the developer: {resp}"
+ );
+ assert!(
+ resp.contains("don't have access"),
+ "denial should come from the variable-RLS gate, not a connection error: {resp}"
+ );
+ assert!(
+ !resp.contains("Failed to connect to MCP server"),
+ "developer must be blocked before the connection step (would mean the token was resolved): {resp}"
+ );
+
+ let (status, resp) = post(&base, RESOURCE_PATH, "SECRET_TOKEN", body).await;
+ assert_ne!(
+ status,
+ reqwest::StatusCode::UNAUTHORIZED,
+ "admin must clear the variable-RLS gate (got {status}): {resp}"
+ );
+ assert!(
+ resp.contains("Failed to connect to MCP server"),
+ "admin should resolve the token and only fail at the connect/SSRF step: {resp}"
+ );
+
+ Ok(())
+}
diff --git a/backend/tests/nativets_stress.rs b/backend/tests/nativets_stress.rs
index 32057b41d2..21d3911778 100644
--- a/backend/tests/nativets_stress.rs
+++ b/backend/tests/nativets_stress.rs
@@ -241,7 +241,6 @@ fn spawn_workers(
worker_name,
i as u64,
n as u32,
- "127.0.0.1",
rx,
tx2,
&base_internal_url,
diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs
index 0f45d09147..b7aa671b21 100644
--- a/backend/tests/python_jobs.rs
+++ b/backend/tests/python_jobs.rs
@@ -1132,7 +1132,8 @@ async def main(item: str, qty: int, email: str):
port,
)
.await;
- });
+ })
+ .await;
Ok(())
}
@@ -1240,6 +1241,50 @@ async fn test_python_wac_v2_with_preprocessor(db: Pool) -> anyhow::Res
Ok(())
}
+#[cfg(feature = "python")]
+#[sqlx::test(fixtures("base", "typechecked_python"))]
+async fn test_typechecked_decorator_python(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+
+ let content = r#"
+from f.system.typechecked_helper import greet
+
+def main():
+ return greet("World")
+"#
+ .to_owned();
+
+ let job = JobPayload::Code(RawCode {
+ hash: None,
+ content,
+ path: Some("f/system/test_typechecked".to_string()),
+ language: ScriptLang::Python3,
+ lock: None,
+ concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
+ .into(),
+ debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
+ cache_ttl: None,
+ cache_ignore_s3_path: None,
+ dedicated_worker: None,
+ modules: None,
+ tag: None,
+ });
+
+ let result = run_job_in_new_worker_until_complete(&db, false, job, port)
+ .await
+ .json_result()
+ .unwrap();
+
+ let result_str = result.as_str().unwrap();
+ assert!(
+ result_str.starts_with("Hello, World! from "),
+ "unexpected result: {result_str}"
+ );
+ Ok(())
+}
+
/// End-to-end comparison between the legacy `step()` suspend-and-replay path
/// and the new SDK inline-persist fast path, toggled per-job via the
/// `WM_WAC_INLINE_FAST_PATH` env var which the Python script sets on its own
diff --git a/backend/tests/worker_ping_ip.rs b/backend/tests/worker_ping_ip.rs
new file mode 100644
index 0000000000..8f98398b40
--- /dev/null
+++ b/backend/tests/worker_ping_ip.rs
@@ -0,0 +1,44 @@
+use sqlx::{Pool, Postgres};
+use windmill_common::{external_ip::UNKNOWN_IP, worker::insert_ping_query};
+
+async fn insert_ping(db: &Pool, worker: &str, ip: Option<&str>) -> anyhow::Result<()> {
+ insert_ping_query(
+ "test-instance",
+ worker,
+ "default",
+ ip,
+ &[],
+ None,
+ None,
+ "test",
+ None,
+ None,
+ None,
+ false,
+ db,
+ )
+ .await?;
+ Ok(())
+}
+
+/// The external IP resolves in the background, so the initial ping often has none yet. That must
+/// not blank the address a previous process wrote to the row this one reclaims — worker names are
+/// stable across restarts under EXIT_AFTER_N_JOBS.
+#[sqlx::test]
+async fn unresolved_ip_keeps_the_reclaimed_rows_address(db: Pool) -> anyhow::Result<()> {
+ insert_ping(&db, "wk-reclaimed", Some("1.2.3.4")).await?;
+ insert_ping(&db, "wk-reclaimed", None).await?;
+ let ip: String = sqlx::query_scalar("SELECT ip FROM worker_ping WHERE worker = $1")
+ .bind("wk-reclaimed")
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(ip, "1.2.3.4");
+
+ insert_ping(&db, "wk-fresh", None).await?;
+ let ip: String = sqlx::query_scalar("SELECT ip FROM worker_ping WHERE worker = $1")
+ .bind("wk-fresh")
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(ip, UNKNOWN_IP);
+ Ok(())
+}
diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs
index 02ab702d31..b223725af2 100644
--- a/backend/windmill-ai/src/ai_bedrock.rs
+++ b/backend/windmill-ai/src/ai_bedrock.rs
@@ -109,6 +109,19 @@ const BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_IDS: &[&str] = &[
"anthropic.claude-3-5-sonnet-20241022-v2:0",
];
+/// Claude 4.6 and later are published under several id spellings for the same
+/// model (`anthropic.claude-sonnet-4-6`, `...-4-6-v1`, `...-4-6-v1:0`), so they
+/// are matched by family prefix rather than by exact id.
+const BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_PREFIXES: &[&str] = &[
+ "anthropic.claude-fable-5",
+ "anthropic.claude-opus-4-6",
+ "anthropic.claude-opus-4-7",
+ "anthropic.claude-opus-4-8",
+ "anthropic.claude-opus-5",
+ "anthropic.claude-sonnet-4-6",
+ "anthropic.claude-sonnet-5",
+];
+
fn build_default_cache_point() -> aws_sdk_bedrockruntime::types::CachePointBlock {
aws_sdk_bedrockruntime::types::CachePointBlock::builder()
.r#type(aws_sdk_bedrockruntime::types::CachePointType::Default)
@@ -123,7 +136,7 @@ fn normalize_bedrock_model_id(model: &str) -> String {
.unwrap_or(model)
.to_ascii_lowercase();
- for prefix in ["global.", "us.", "eu.", "apac."] {
+ for prefix in ["global.", "us.", "eu.", "apac.", "au."] {
if let Some(normalized_model) = model.strip_prefix(prefix) {
return normalized_model.to_string();
}
@@ -135,6 +148,9 @@ fn normalize_bedrock_model_id(model: &str) -> String {
pub fn bedrock_model_supports_prompt_caching(model: &str) -> bool {
let normalized_model = normalize_bedrock_model_id(model);
BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_IDS.contains(&normalized_model.as_str())
+ || BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_PREFIXES
+ .iter()
+ .any(|prefix| normalized_model.starts_with(prefix))
}
fn append_cache_point_to_system_prompts(system_prompts: &mut Vec) {
@@ -1241,6 +1257,27 @@ mod tests {
));
}
+ /// Claude 4.6+ ships under bare, `-v1` and `-v1:0` spellings of the same id,
+ /// so every one of them has to reach the prefix match.
+ #[test]
+ fn bedrock_prompt_caching_supports_claude_4_6_and_later_id_spellings() {
+ for model in [
+ "anthropic.claude-sonnet-4-6",
+ "anthropic.claude-sonnet-4-6-v1:0",
+ "us.anthropic.claude-opus-4-6-v1",
+ "global.anthropic.claude-opus-4-8",
+ "anthropic.claude-opus-5",
+ "eu.anthropic.claude-sonnet-5-v1:0",
+ "au.anthropic.claude-sonnet-5",
+ "anthropic.claude-fable-5",
+ ] {
+ assert!(
+ bedrock_model_supports_prompt_caching(model),
+ "{model} must support prompt caching"
+ );
+ }
+ }
+
#[test]
fn bedrock_prompt_caching_rejects_unsupported_or_opaque_model_ids() {
assert!(!bedrock_model_supports_prompt_caching(
@@ -1249,5 +1286,9 @@ mod tests {
assert!(!bedrock_model_supports_prompt_caching(
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile"
));
+ // Opus 4.5 is dated-id only — the 4.6+ prefixes must not swallow it.
+ assert!(!bedrock_model_supports_prompt_caching(
+ "anthropic.claude-opus-4-5-20251101-v2:0"
+ ));
}
}
diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs
index 6ebc555da6..f22f28de14 100644
--- a/backend/windmill-ai/src/providers/anthropic.rs
+++ b/backend/windmill-ai/src/providers/anthropic.rs
@@ -1,3 +1,4 @@
+use super::{anthropic_model_rejects_sampling_params, REASONING_OFF_SENTINEL};
use crate::{
ai_google::parse_data_url,
ai_providers::{AIPlatform, AIProvider},
@@ -137,17 +138,23 @@ pub struct AnthropicMessage {
pub content: Vec,
}
-/// Adaptive thinking config for Anthropic native API. `summarized` display
-/// matches the chat proxy path (renders a summarized thinking stream).
+/// Thinking config for the Anthropic native API. `summarized` display matches
+/// the chat proxy path (renders a summarized thinking stream); the disable
+/// carries no display.
#[derive(Serialize, Debug)]
pub struct AnthropicThinking {
pub r#type: &'static str,
- pub display: &'static str,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub display: Option<&'static str>,
}
impl AnthropicThinking {
fn adaptive() -> Self {
- Self { r#type: "adaptive", display: "summarized" }
+ Self { r#type: "adaptive", display: Some("summarized") }
+ }
+
+ fn disabled() -> Self {
+ Self { r#type: "disabled", display: None }
}
}
@@ -157,6 +164,37 @@ pub struct AnthropicOutputConfig {
pub effort: String,
}
+/// Resolve the thinking config, effort and sampling params for a reasoning
+/// selection. Temperature is dropped both under adaptive thinking, which
+/// rejects it, and on the models that removed the sampling params outright.
+fn anthropic_thinking_config(
+ model: &str,
+ reasoning_effort: Option<&str>,
+ temperature: Option,
+) -> (
+ Option,
+ Option,
+ Option,
+) {
+ let temperature = (!anthropic_model_rejects_sampling_params(model))
+ .then_some(temperature)
+ .flatten();
+ match reasoning_effort {
+ // The disable sentinel is not an effort token — Anthropic's vocabulary
+ // is low..max and rejects it. The disable carries no effort either:
+ // pairing it with xhigh or max is itself a 400 on Opus 5.
+ Some(effort) if effort == REASONING_OFF_SENTINEL => {
+ (Some(AnthropicThinking::disabled()), None, temperature)
+ }
+ Some(effort) => (
+ Some(AnthropicThinking::adaptive()),
+ Some(AnthropicOutputConfig { effort: effort.to_string() }),
+ None,
+ ),
+ None => (None, None, temperature),
+ }
+}
+
/// Anthropic-specific request structure for standard API
#[derive(Serialize)]
pub struct AnthropicRequest<'a> {
@@ -652,16 +690,8 @@ impl AnthropicQueryBuilder {
}
}
- // Adaptive thinking rejects sampling params, so drop temperature when
- // reasoning is on (Anthropic returns a hard 400 otherwise).
- let (thinking, output_config, temperature) = match args.reasoning_effort {
- Some(effort) => (
- Some(AnthropicThinking::adaptive()),
- Some(AnthropicOutputConfig { effort: effort.to_string() }),
- None,
- ),
- None => (None, None, args.temperature),
- };
+ let (thinking, output_config, temperature) =
+ anthropic_thinking_config(args.model, args.reasoning_effort, args.temperature);
// Build request based on platform
if self.is_vertex() {
@@ -1096,6 +1126,83 @@ mod tests {
assert!(body.get("temperature").is_none());
}
+ /// An agent step stores the chat's off sentinel verbatim as its
+ /// `reasoning_effort`, so the disable has to be translated here rather than
+ /// forwarded as an effort token Anthropic would reject.
+ #[test]
+ fn anthropic_thinking_config_translates_the_off_sentinel() {
+ let (thinking, output_config, _) =
+ anthropic_thinking_config("claude-sonnet-4-6", Some("none"), Some(0.5));
+ assert_eq!(thinking.as_ref().map(|t| t.r#type), Some("disabled"));
+ assert!(output_config.is_none());
+
+ let (thinking, output_config, temperature) =
+ anthropic_thinking_config("claude-sonnet-4-6", Some("xhigh"), Some(0.5));
+ assert_eq!(thinking.as_ref().map(|t| t.r#type), Some("adaptive"));
+ assert_eq!(output_config.map(|c| c.effort), Some("xhigh".to_string()));
+ // Adaptive thinking rejects sampling params on every model.
+ assert!(temperature.is_none());
+
+ let (thinking, output_config, temperature) =
+ anthropic_thinking_config("claude-sonnet-4-6", None, Some(0.5));
+ assert!(thinking.is_none());
+ assert!(output_config.is_none());
+ assert_eq!(temperature, Some(0.5));
+ }
+
+ /// Live-verified: Opus 4.8 and the 5 family 400 with `temperature is
+ /// deprecated for this model` whatever the thinking mode, so the disable and
+ /// no-reasoning paths have to drop it too.
+ #[test]
+ fn anthropic_thinking_config_drops_sampling_params_on_models_that_reject_them() {
+ for model in [
+ "claude-opus-5",
+ "claude-sonnet-5",
+ "claude-opus-4-8",
+ "anthropic/claude-opus-4.7",
+ "claude-fable-5",
+ ] {
+ for effort in [Some("none"), None] {
+ let (_, _, temperature) = anthropic_thinking_config(model, effort, Some(0.5));
+ assert!(
+ temperature.is_none(),
+ "{model} must not carry temperature (effort {effort:?})"
+ );
+ }
+ }
+ // Sonnet 4.6 still accepts them, so an off selection keeps temperature.
+ let (_, _, temperature) =
+ anthropic_thinking_config("claude-sonnet-4-6", Some("none"), Some(0.5));
+ assert_eq!(temperature, Some(0.5));
+ }
+
+ #[test]
+ fn anthropic_request_serializes_the_off_sentinel_as_a_thinking_disable() {
+ let (thinking, output_config, temperature) =
+ anthropic_thinking_config("claude-opus-5", Some("none"), Some(0.5));
+ let request = AnthropicRequest {
+ model: "claude-opus-5",
+ system: None,
+ messages: vec![],
+ tools: None,
+ tool_choice: None,
+ temperature,
+ thinking,
+ output_config,
+ max_tokens: Some(64000),
+ stream: true,
+ };
+
+ let body: serde_json::Value =
+ serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap();
+ assert_eq!(body["thinking"]["type"], "disabled");
+ // A disable paired with an effort is a 400 on Opus 5, and `display`
+ // only applies to a thinking mode that actually runs.
+ assert!(body["thinking"].get("display").is_none());
+ assert!(body.get("output_config").is_none());
+ assert!(body.get("temperature").is_none());
+ }
+
#[test]
fn anthropic_request_omits_thinking_when_reasoning_off() {
let request = AnthropicRequest {
diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs
index 4405a50dcb..453607eea5 100644
--- a/backend/windmill-ai/src/providers/bedrock.rs
+++ b/backend/windmill-ai/src/providers/bedrock.rs
@@ -6,6 +6,7 @@
//! - Stream event parsing
//! - Helper utilities
+use super::{anthropic_model_rejects_sampling_params, REASONING_OFF_SENTINEL};
use crate::{
ai_bedrock::{
bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop,
@@ -357,12 +358,11 @@ async fn handle_bedrock_sdk_streaming(
let enable_prompt_caching = bedrock_model_supports_prompt_caching(model);
let (bedrock_messages, system_prompts) =
openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?;
- // Adaptive thinking rejects sampling params; drop temperature when reasoning is on.
- let temperature = openai_req
- .reasoning_effort
- .is_none()
- .then_some(openai_req.temperature)
- .flatten();
+ let temperature = bedrock_temperature(
+ model,
+ openai_req.reasoning_effort.as_deref(),
+ openai_req.temperature,
+ );
let inference_config = create_inference_config(temperature, openai_req.max_tokens);
let tool_config = build_tool_config_from_request(
openai_req.tools.as_deref(),
@@ -410,11 +410,35 @@ async fn handle_bedrock_sdk_streaming(
})
}
-/// Build the Converse `additionalModelRequestFields` enabling Claude adaptive
-/// thinking at the given effort. `display: summarized` is billing-neutral on
-/// Anthropic models and matches the direct-Anthropic chat path, which renders
-/// summarized thinking in the UI.
+/// Whether an effort token turns adaptive thinking on. `"none"` is the disable
+/// sentinel rather than a level.
+fn effort_enables_thinking(effort: Option<&str>) -> bool {
+ matches!(effort, Some(effort) if effort != REASONING_OFF_SENTINEL)
+}
+
+/// Temperature survives only when thinking is not adaptive — which rejects
+/// sampling params — and the model still accepts them at all. Non-Anthropic
+/// Bedrock models (Nova, Llama) are unaffected by the second check.
+fn bedrock_temperature(model: &str, effort: Option<&str>, temperature: Option) -> Option {
+ if effort_enables_thinking(effort) || anthropic_model_rejects_sampling_params(model) {
+ return None;
+ }
+ temperature
+}
+
+/// Build the Converse `additionalModelRequestFields` carrying Claude's thinking
+/// config. `display: summarized` is billing-neutral on Anthropic models and
+/// matches the direct-Anthropic chat path, which renders summarized thinking in
+/// the UI.
fn bedrock_thinking_fields(effort: &str) -> aws_smithy_types::Document {
+ if effort == REASONING_OFF_SENTINEL {
+ // The disable carries no effort: pairing it with xhigh or max is a 400
+ // on Opus 5, and omitting it leaves the model at the effort where the
+ // disable is accepted.
+ return json_to_document(serde_json::json!({
+ "thinking": { "type": "disabled" }
+ }));
+ }
json_to_document(serde_json::json!({
"thinking": { "type": "adaptive", "display": "summarized" },
"output_config": { "effort": effort }
@@ -663,12 +687,11 @@ async fn handle_bedrock_sdk_non_streaming(
let enable_prompt_caching = bedrock_model_supports_prompt_caching(model);
let (bedrock_messages, system_prompts) =
openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?;
- // Adaptive thinking rejects sampling params; drop temperature when reasoning is on.
- let temperature = openai_req
- .reasoning_effort
- .is_none()
- .then_some(openai_req.temperature)
- .flatten();
+ let temperature = bedrock_temperature(
+ model,
+ openai_req.reasoning_effort.as_deref(),
+ openai_req.temperature,
+ );
let inference_config = create_inference_config(temperature, openai_req.max_tokens);
let tool_config = build_tool_config_from_request(
openai_req.tools.as_deref(),
@@ -934,8 +957,7 @@ impl BedrockQueryBuilder {
let (bedrock_messages, system_prompts) =
openai_messages_to_bedrock(&prepared_messages, enable_prompt_caching)?;
- // Adaptive thinking rejects sampling params; drop temperature when reasoning is on.
- let temperature = reasoning_effort.is_none().then_some(temperature).flatten();
+ let temperature = bedrock_temperature(model, reasoning_effort, temperature);
// Build inference configuration using shared helper
let inference_config = create_inference_config(temperature, max_tokens.map(|t| t as i32));
@@ -1285,6 +1307,37 @@ mod tests {
);
}
+ #[test]
+ fn bedrock_thinking_fields_translate_the_off_sentinel_to_a_disable() {
+ let fields = document_to_json(&bedrock_thinking_fields("none"));
+ assert_eq!(fields["thinking"]["type"], "disabled");
+ // An effort alongside the disable is a 400 on Opus 5.
+ assert!(fields.get("output_config").is_none());
+ assert!(effort_enables_thinking(Some("xhigh")));
+ assert!(!effort_enables_thinking(Some("none")));
+ assert!(!effort_enables_thinking(None));
+ }
+
+ #[test]
+ fn bedrock_temperature_drops_sampling_params_per_thinking_mode_and_model() {
+ // Adaptive thinking rejects sampling params on every model...
+ let adaptive = bedrock_temperature("anthropic.claude-sonnet-4-6", Some("xhigh"), Some(0.5));
+ assert!(adaptive.is_none());
+ // ...while a model that still accepts them keeps them when off.
+ let off = bedrock_temperature("anthropic.claude-sonnet-4-6", Some("none"), Some(0.5));
+ assert_eq!(off, Some(0.5));
+ // The models that removed them drop them on every mode.
+ for (model, effort) in [
+ ("global.anthropic.claude-opus-5", Some("none")),
+ ("anthropic.claude-opus-4-8", None),
+ ] {
+ assert!(bedrock_temperature(model, effort, Some(0.5)).is_none());
+ }
+ // A non-Anthropic Bedrock model keeps its sampling params.
+ let nova = bedrock_temperature("amazon.nova-pro-v1:0", None, Some(0.5));
+ assert_eq!(nova, Some(0.5));
+ }
+
#[test]
fn bedrock_thinking_fields_carry_adaptive_thinking_and_effort() {
let fields = document_to_json(&bedrock_thinking_fields("xhigh"));
diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs
index 094cc71490..af9ba3ff9f 100644
--- a/backend/windmill-ai/src/providers/mod.rs
+++ b/backend/windmill-ai/src/providers/mod.rs
@@ -8,6 +8,38 @@ pub mod other;
use std::time::{Duration, Instant};
+/// The effort token the chat and agent surfaces send to turn reasoning off.
+/// It is not a provider-native level — each provider translates it to its own
+/// disable (Anthropic and Bedrock to `thinking: {type: "disabled"}`, DeepSeek to
+/// its `thinking` param, Gemini to a zero budget or the model's floor).
+pub(crate) const REASONING_OFF_SENTINEL: &str = "none";
+
+/// Whether a Claude model removed the sampling params (`temperature`, `top_p`,
+/// `top_k`). On these, any value is a hard 400 — `temperature is deprecated for
+/// this model` — whatever the thinking mode, so the param has to be dropped on
+/// the reasoning-off and no-reasoning paths too, not only under adaptive
+/// thinking.
+///
+/// Probed against the Messages API: `claude-opus-5`, `claude-sonnet-5` and
+/// `claude-opus-4-8` reject them; `claude-sonnet-4-6` still accepts them. Opus
+/// 4.7, Fable and Mythos are included from Anthropic's migration guide, which
+/// documents the same removal, rather than from a probe.
+///
+/// Matching is on the model name, so a Bedrock *application* inference profile —
+/// whose id is opaque (`k1c3lwu20lem`) rather than derived from the model —
+/// cannot be classified and keeps its sampling params. Resolving the backing
+/// model would need a per-request AWS lookup; `bedrock_model_supports_prompt_caching`
+/// degrades on the same ids for the same reason.
+pub(crate) fn anthropic_model_rejects_sampling_params(model: &str) -> bool {
+ let model = model.to_lowercase().replace('.', "-");
+ model.contains("claude-opus-4-7")
+ || model.contains("claude-opus-4-8")
+ || model.contains("claude-opus-5")
+ || model.contains("claude-sonnet-5")
+ || model.contains("claude-fable")
+ || model.contains("claude-mythos")
+}
+
use windmill_common::cache::Cache;
use crate::{
diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs
index d92c3a5f2d..88eb11093a 100644
--- a/backend/windmill-api-auth/src/lib.rs
+++ b/backend/windmill-api-auth/src/lib.rs
@@ -67,8 +67,9 @@ pub struct ApiAuthed {
/// `label-*` string. Only `username_override_from_label` sets it.
pub username_override_is_token_label: bool,
/// Whether the request authenticated with the session token minted at browser login.
- /// Only `trigger_or_fallback` reads it — see `is_session_label` for why it attributes
- /// rather than proves, and must not gate authority.
+ /// Read by `trigger_or_fallback` and by `TriggerSource::of_request` (which attributes a
+ /// trigger mutation to the UI) — see `is_session_label` for why it attributes rather than
+ /// proves, and must not gate authority.
pub is_session_token: bool,
pub token_prefix: Option,
pub read_only: bool,
diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs
index ed273c7d15..26dd523294 100644
--- a/backend/windmill-api-auth/src/scopes.rs
+++ b/backend/windmill-api-auth/src/scopes.rs
@@ -274,6 +274,7 @@ pub enum ScopeDomain {
// Native trigger domains
NativeTriggers,
+ TriggersHistory,
// System domains
Audit,
@@ -335,6 +336,7 @@ impl ScopeDomain {
Self::PostgresTriggers => "postgres_triggers",
Self::EmailTriggers => "email_triggers",
Self::NativeTriggers => "native_triggers",
+ Self::TriggersHistory => "triggers_history",
Self::Audit => "audit",
Self::Settings => "settings",
Self::Workers => "workers",
@@ -401,6 +403,7 @@ impl ScopeDomain {
"indexer" | "srch" => Some(Self::Indexer),
"teams" => Some(Self::Teams),
"native_triggers" => Some(Self::NativeTriggers),
+ "triggers_history" => Some(Self::TriggersHistory),
"git_sync" | "github_app" => Some(Self::GitSync),
"capture" => Some(Self::Capture),
"drafts" => Some(Self::Drafts),
@@ -689,27 +692,49 @@ fn extract_domain_from_route(
)))
}
-const RUN_WHITELISTED_GET_PATHS: [&'static str; 20] = [
+/// The reads a `jobs:run` scope implies: following, by id, a run the token started.
+/// Every entry is keyed by a job id and confines an authenticated caller to its own
+/// runnable — through `require_job_read_access`, through its own `jobs:run:flows:`
+/// check, or, where an approval token or resume secret bypasses that gate, through a
+/// direct `require_job_within_run_scope`. The one exception is
+/// `jobs_u/get_root_job_id/`, which has no check at all but discloses only flow lineage,
+/// to anyone, authenticated or not. Workspace-wide enumeration (`jobs/list`, counts,
+/// exports) and credential minting (`job_view_token`) are deliberately absent — those are
+/// `jobs:read`. Keep by-id read routes here in sync as they are added, or a run token
+/// loses the ability to follow its own run through them.
+const RUN_WHITELISTED_GET_PATHS: [&'static str; 32] = [
"jobs_u/get_flow/",
"jobs_u/get_root_job_id/",
"jobs_u/get/",
"jobs_u/get_logs/",
+ "jobs_u/get_completed_logs_tail/",
"jobs_u/get_flow_all_logs/",
+ "jobs_u/get_flow_all_logs_structured/",
+ "jobs_u/get_flow_all_results/",
"jobs_u/get_args/",
"jobs_u/get_flow_debug_info/",
"jobs_u/completed/get/",
"jobs_u/completed/get_result/",
"jobs_u/completed/get_result_maybe/",
+ "jobs_u/completed/get_timing/",
+ "jobs_u/dispatch_events/",
"jobs_u/getupdate/",
"jobs_u/getupdate_sse/",
"jobs_u/get_log_file/",
+ "jobs/run_progress/",
+ "jobs/dbt_graph/",
+ "jobs/dbt_resumable/",
+ "jobs/dbt_resumable_script/p/",
"jobs/result_by_id/",
"jobs/resume_urls/",
"jobs/flow/user_states/",
"jobs/job_signature/",
+ "jobs/wac_approval_urls/",
"jobs/completed/get/",
"jobs/completed/get_result/",
"jobs/completed/get_result_maybe/",
+ "jobs/completed/get_timing/",
+ "jobs/get_otel_traces/",
];
/// Sentinel scope in app embed tokens. Grants nothing itself; `check_route_access`
@@ -826,6 +851,60 @@ fn resource_metadata_route_allowed(suffix: &str) -> bool {
|| suffix.starts_with("resources/type/")
}
+/// The `jobs:run` scopes a token's job reads are confined to, or `None` when they are
+/// not confined to particular runnables.
+///
+/// A run scope is what the trigger UI mints per script or flow and hands to a webhook
+/// caller / CI job: it may start the runnables it names and follow those runs, so its
+/// by-id job reads must stay within what it can start (enforced by
+/// `require_job_read_access`). Both the path (`jobs:run:flows:f/team/etl`) and the
+/// kind-only (`jobs:run:scripts`, which legacy `jobs:runscript` tokens carry) forms
+/// confine, since `ScopeDefinition::includes` already matches a candidate
+/// `jobs:run::` against either.
+///
+/// Returns `None` — unconfined — when the token is effectively unscoped, or carries a
+/// jobs scope that grants job reads in its own right: `jobs:read`/`jobs:write`, or a
+/// bare `jobs:run` (it can start anything, so confining its reads to "what it may run"
+/// would restrict nothing).
+pub fn job_read_run_confinement(scopes: Option<&[String]>) -> Option> {
+ let mut confinement = Vec::new();
+ for scope in scopes?
+ .iter()
+ .filter(|s| !s.starts_with("if_jobs:filter_tags:"))
+ {
+ let Ok(scope) = ScopeDefinition::from_scope_string(scope) else {
+ continue;
+ };
+ if ScopeDomain::from_str(&scope.domain) != Some(ScopeDomain::Jobs) {
+ continue;
+ }
+ match ScopeAction::from_str(&scope.action) {
+ Some(ScopeAction::Run) if scope.kind.is_some() || scope.resource.is_some() => {
+ confinement.push(scope)
+ }
+ Some(_) => return None,
+ None => continue,
+ }
+ }
+ (!confinement.is_empty()).then_some(confinement)
+}
+
+/// Whether a job that ran `runnable_path` as `kind` (`scripts` or `flows`) is inside a
+/// [`job_read_run_confinement`] set.
+pub fn run_confinement_admits(
+ confinement: &[ScopeDefinition],
+ kind: &str,
+ runnable_path: &str,
+) -> bool {
+ let required = ScopeDefinition::new(
+ ScopeDomain::Jobs.as_str(),
+ ScopeAction::Run.as_str(),
+ Some(kind),
+ Some(vec![runnable_path.to_string()]),
+ );
+ confinement.iter().any(|scope| scope.includes(&required))
+}
+
fn scope_grants_access(
scope: &ScopeDefinition,
required_domain: ScopeDomain,
@@ -862,15 +941,26 @@ fn scope_grants_access(
return Ok(true);
}
- if !scope_action.includes(&required_action)
- && !(scope_domain == ScopeDomain::Jobs
- && required_action == ScopeAction::Read
- && route_path.is_some_and(|p| {
- RUN_WHITELISTED_GET_PATHS
- .iter()
- .any(|path| p.starts_with(path))
- }))
+ // `jobs:run` is a grant to *start* a runnable. The only reads it implies are the
+ // by-id routes a caller needs to follow the run it started
+ // (`RUN_WHITELISTED_GET_PATHS`) — never workspace-wide enumeration (`jobs/list`,
+ // counts, exports), which is what `jobs:read` is for. Those by-id reads are in turn
+ // confined to the runnable a path-scoped token names, by `require_job_read_access`.
+ // `ScopeAction::Run.includes(&Read)` (which exists so `apps:run` can fetch the app
+ // it runs) must not reach this domain, so decide it here rather than falling
+ // through to the hierarchy below.
+ if scope_domain == ScopeDomain::Jobs
+ && scope_action == ScopeAction::Run
+ && required_action == ScopeAction::Read
{
+ return Ok(route_path.is_some_and(|p| {
+ RUN_WHITELISTED_GET_PATHS
+ .iter()
+ .any(|path| p.starts_with(path))
+ }));
+ }
+
+ if !scope_action.includes(&required_action) {
return Ok(false);
}
@@ -1099,6 +1189,65 @@ mod tests {
.is_err());
}
+ #[test]
+ fn jobs_run_reads_are_limited_to_the_by_id_poll_routes() {
+ let job = "/api/w/test/jobs_u/completed/get_result/019ff012-6b1e-0d6b-fc0d-0c85d34d9cec";
+ let list = "/api/w/test/jobs/list";
+ for scope in ["jobs:run", "jobs:run:scripts:u/admin/script"] {
+ // Following the run it started stays available...
+ assert!(
+ check_route_access(&[scope.to_string()], job, "GET").is_ok(),
+ "{scope} must reach the by-id job poll routes"
+ );
+ // ...but a run grant is not a licence to enumerate the workspace's jobs.
+ assert!(
+ check_route_access(&[scope.to_string()], list, "GET").is_err(),
+ "{scope} must not reach jobs/list"
+ );
+ }
+ assert!(check_route_access(&["jobs:read".to_string()], list, "GET").is_ok());
+ }
+
+ #[test]
+ fn run_scopes_confine_job_reads_by_kind_and_path() {
+ let confinement =
+ job_read_run_confinement(Some(&["jobs:run:flows:f/team/*".to_string()])).unwrap();
+ assert!(run_confinement_admits(&confinement, "flows", "f/team/etl"));
+ // Right path, wrong kind — a script named like the flow is not the flow.
+ assert!(!run_confinement_admits(
+ &confinement,
+ "scripts",
+ "f/team/etl"
+ ));
+ assert!(!run_confinement_admits(
+ &confinement,
+ "flows",
+ "f/other/etl"
+ ));
+
+ // A kind-only scope confines to that kind, at any path.
+ let kind_only = job_read_run_confinement(Some(&["jobs:run:scripts".to_string()])).unwrap();
+ assert!(run_confinement_admits(&kind_only, "scripts", "u/admin/anything"));
+ assert!(!run_confinement_admits(&kind_only, "flows", "f/team/etl"));
+
+ // Scopes that grant job reads in their own right leave reads unconfined.
+ for scopes in [
+ vec!["jobs:read".to_string()],
+ vec!["jobs:run".to_string()],
+ vec![
+ "jobs:run:scripts:u/admin/script".to_string(),
+ "jobs:read".to_string(),
+ ],
+ vec!["if_jobs:filter_tags:deno".to_string()],
+ ] {
+ assert!(
+ job_read_run_confinement(Some(&scopes)).is_none(),
+ "{scopes:?} must not confine job reads"
+ );
+ }
+ assert!(job_read_run_confinement(None).is_none());
+ }
+
#[test]
fn test_new_domain_parsing() {
// Test that new domains are properly parsed
diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs
index d2a6c7d16e..69b50e4b83 100644
--- a/backend/windmill-api-groups/src/groups.rs
+++ b/backend/windmill-api-groups/src/groups.rs
@@ -514,6 +514,143 @@ async fn update_igroup(
Ok(format!("Updated group {}", name))
}
+/// Workspaces whose auto-assignment config references any of `groups`.
+///
+/// Reads `workspace_settings` across the whole instance without checking the caller's rights.
+/// Callers must have established superadmin beforehand; the result leaks which workspaces are
+/// configured with a given instance group.
+///
+/// This and every reconcile call site are gated on `private` alone, NOT `enterprise`: CE
+/// builds ship `private` without `enterprise`, and gating on `enterprise` would scrub
+/// references while stranding the affected workspace members on CE.
+#[cfg(feature = "private")]
+pub async fn workspaces_referencing_instance_groups(
+ groups: &[String],
+ tx: &mut Transaction<'_, Postgres>,
+) -> Result> {
+ if groups.is_empty() {
+ return Ok(vec![]);
+ }
+
+ let workspaces = sqlx::query_scalar!(
+ "SELECT workspace_id FROM workspace_settings WHERE auto_invite->'instance_groups' ?| $1",
+ groups
+ )
+ .fetch_all(&mut **tx)
+ .await?;
+
+ Ok(workspaces)
+}
+
+/// Compute and advisory-lock every workspace whose auto-assignment config references any of
+/// `groups`. Mutation paths call this after locking their `instance_group` rows and before
+/// any other row lock — the hierarchy is group rows → workspace advisory locks → all other
+/// row locks (see `reconcile_workspace_instance_groups`). Same authorization contract as
+/// `workspaces_referencing_instance_groups`.
+#[cfg(feature = "private")]
+pub async fn lock_workspaces_referencing_instance_groups(
+ groups: &[String],
+ tx: &mut Transaction<'_, Postgres>,
+) -> Result> {
+ use windmill_api_workspaces::workspaces_ee::lock_instance_group_workspaces;
+
+ let workspaces = workspaces_referencing_instance_groups(groups, tx).await?;
+ lock_instance_group_workspaces(&workspaces, tx).await?;
+ Ok(workspaces)
+}
+
+/// Drop `groups` from every workspace's instance-group auto-assignment config.
+///
+/// Workspaces reference instance groups by name in `workspace_settings.auto_invite`, and
+/// nothing in the schema ties those references to `instance_group` rows. A deleted group whose
+/// name is left behind here silently re-acquires its members if a group of the same name is
+/// created later.
+///
+/// Mutates every workspace's settings, so callers must have established superadmin first.
+/// Deliberately not audited per workspace: the mutation is instance-scoped and recorded by
+/// the caller's global igroup audit event.
+pub async fn remove_instance_groups_from_workspace_settings(
+ groups: &[String],
+ tx: &mut Transaction<'_, Postgres>,
+) -> Result<()> {
+ if groups.is_empty() {
+ return Ok(());
+ }
+
+ // Row filter must stay `?|`: it yields false on a JSON `null` instance_groups, where
+ // jsonb_array_elements_text would instead raise and abort the whole transaction; the
+ // jsonb_typeof guard rules out the same class of value for the roles object. The filter is
+ // not index-backed — the GIN index covers the auto_invite column, not this expression —
+ // which is acceptable since workspace_settings holds one row per workspace.
+ sqlx::query!(
+ r#"UPDATE workspace_settings SET
+ auto_invite = jsonb_set(
+ jsonb_set(
+ COALESCE(auto_invite, '{}'::jsonb),
+ '{instance_groups}',
+ (SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)
+ FROM jsonb_array_elements(COALESCE(auto_invite->'instance_groups', '[]'::jsonb)) elem
+ WHERE elem #>> '{}' <> ALL($1))
+ ),
+ '{instance_groups_roles}',
+ CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'
+ THEN (auto_invite->'instance_groups_roles') - $1::text[]
+ ELSE '{}'::jsonb
+ END
+ )
+ WHERE auto_invite->'instance_groups' ?| $1"#,
+ groups
+ )
+ .execute(&mut **tx)
+ .await?;
+
+ Ok(())
+}
+
+/// Follow an instance-group rename through every workspace's auto-assignment config.
+///
+/// Workspaces reference instance groups by name, so a rename that leaves the old name behind
+/// strands those references: the reconciler resolves membership from the groups a workspace
+/// references, and a name that no longer matches any group reads as "no members", which would
+/// evict everyone granted through it on the next reconcile.
+///
+/// Mutates every workspace's settings, so callers must have established superadmin first.
+/// Deliberately not audited per workspace: the mutation is instance-scoped and recorded by
+/// the caller's global igroup audit event.
+pub async fn rename_instance_group_in_workspace_settings(
+ old_name: &str,
+ new_name: &str,
+ tx: &mut Transaction<'_, Postgres>,
+) -> Result<()> {
+ // Row filter must stay `?`: it yields false on a JSON `null` instance_groups, where
+ // jsonb_array_elements would instead raise and abort the whole transaction.
+ sqlx::query!(
+ r#"UPDATE workspace_settings SET
+ auto_invite = jsonb_set(
+ jsonb_set(
+ COALESCE(auto_invite, '{}'::jsonb),
+ '{instance_groups}',
+ (SELECT COALESCE(jsonb_agg(
+ CASE WHEN elem #>> '{}' = $1 THEN to_jsonb($2::text) ELSE elem END), '[]'::jsonb)
+ FROM jsonb_array_elements(COALESCE(auto_invite->'instance_groups', '[]'::jsonb)) elem)
+ ),
+ '{instance_groups_roles}',
+ CASE WHEN COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb) ? $1
+ THEN (COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb) - $1)
+ || jsonb_build_object($2::text, auto_invite->'instance_groups_roles'->$1)
+ ELSE COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb)
+ END
+ )
+ WHERE auto_invite->'instance_groups' ? $1"#,
+ old_name,
+ new_name
+ )
+ .execute(&mut **tx)
+ .await?;
+
+ Ok(())
+}
+
async fn delete_igroup(
authed: ApiAuthed,
Extension(db): Extension,
@@ -522,9 +659,10 @@ async fn delete_igroup(
require_super_admin(&db, &authed.email).await?;
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
- // Fetch group's instance_role and members before deletion
+ // FOR UPDATE: the group row is the group-level mutex, taken before the workspace
+ // advisory locks (see reconcile_workspace_instance_groups).
let group_role = sqlx::query_scalar!(
- "SELECT instance_role FROM instance_group WHERE name = $1",
+ "SELECT instance_role FROM instance_group WHERE name = $1 FOR UPDATE",
&name
)
.fetch_optional(&mut *tx)
@@ -539,6 +677,13 @@ async fn delete_igroup(
vec![]
};
+ // Captured and advisory-locked before the settings update strips the group from them.
+ #[cfg(feature = "private")]
+ let affected_workspaces =
+ lock_workspaces_referencing_instance_groups(std::slice::from_ref(&name), &mut tx).await?;
+
+ remove_instance_groups_from_workspace_settings(std::slice::from_ref(&name), &mut tx).await?;
+
sqlx::query!("DELETE FROM email_to_igroup WHERE igroup = $1", name)
.execute(&mut *tx)
.await?;
@@ -553,6 +698,12 @@ async fn delete_igroup(
apply_instance_role(email, effective_role.as_deref(), &mut tx).await?;
}
+ #[cfg(feature = "private")]
+ {
+ use windmill_api_workspaces::workspaces_ee::reconcile_workspace_instance_groups;
+ reconcile_workspace_instance_groups(&affected_workspaces, &mut tx, &authed).await?;
+ }
+
audit_log(
&mut *tx,
&authed,
@@ -823,12 +974,22 @@ async fn add_user_igroup(
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
- let group_opt = sqlx::query_scalar!("SELECT name FROM instance_group WHERE name = $1", name)
- .fetch_optional(&mut *tx)
- .await?;
+ // FOR UPDATE: the group row is the group-level mutex, taken before the workspace
+ // advisory locks (see reconcile_workspace_instance_groups).
+ let group_opt = sqlx::query_scalar!(
+ "SELECT name FROM instance_group WHERE name = $1 FOR UPDATE",
+ name
+ )
+ .fetch_optional(&mut *tx)
+ .await?;
not_found_if_none(group_opt, "IGroup", &name)?;
+ // Before the membership insert's row lock.
+ #[cfg(feature = "private")]
+ let affected_workspaces =
+ lock_workspaces_referencing_instance_groups(std::slice::from_ref(&name), &mut tx).await?;
+
sqlx::query!(
"INSERT INTO email_to_igroup (email, igroup) VALUES ($1, $2) ON CONFLICT DO NOTHING",
email,
@@ -848,86 +1009,17 @@ async fn add_user_igroup(
)
.await?;
- // Sync user to workspaces configured with this instance group
- #[cfg(all(feature = "private", feature = "enterprise"))]
- {
- use windmill_api_workspaces::workspaces_ee::auto_add_user;
- use windmill_common::users::compute_highest_workspace_role;
-
- // Find all instance groups this user belongs to (includes the newly added group)
- let user_igroups: Vec = sqlx::query_scalar!(
- "SELECT igroup FROM email_to_igroup WHERE email = $1",
- &email
- )
- .fetch_all(&mut *tx)
- .await?;
-
- let workspaces = sqlx::query!(
- r#"
- SELECT workspace_id,
- auto_invite->'instance_groups_roles' as instance_groups_roles,
- auto_invite->'instance_groups' as instance_groups_json
- FROM workspace_settings
- WHERE auto_invite->'instance_groups' ? $1
- "#,
- &name
- )
- .fetch_all(&mut *tx)
- .await?;
-
- for ws in workspaces {
- let roles: std::collections::HashMap = ws
- .instance_groups_roles
- .and_then(|r| serde_json::from_value(r).ok())
- .unwrap_or_default();
-
- let ws_configured_groups: Vec = ws
- .instance_groups_json
- .and_then(|ig| serde_json::from_value(ig).ok())
- .unwrap_or_default();
-
- let (best_group, is_admin, is_operator) =
- compute_highest_workspace_role(&user_igroups, &ws_configured_groups, &roles);
-
- let instance_group_source = serde_json::json!({
- "source": "instance_group",
- "group": &best_group
- });
-
- // auto_add_user creates the user if they don't exist (ON CONFLICT DO NOTHING).
- // The operator flag here doesn't matter for the final state — the UPDATE below
- // always sets the correct is_admin/operator based on the highest-precedence role.
- auto_add_user(
- &email,
- &ws.workspace_id,
- &false,
- &mut tx,
- &authed,
- Some(instance_group_source.clone()),
- )
- .await?;
-
- // Set the correct role based on highest precedence across all groups.
- // For new users, auto_add_user already stored added_via with source=instance_group,
- // so this UPDATE will match. For existing instance_group users, it upgrades/corrects
- // the role. Manually-added users (added_via is NULL or non-instance_group) are not affected.
- sqlx::query!(
- "UPDATE usr SET is_admin = $1, operator = $2, added_via = $3 WHERE workspace_id = $4 AND email = $5 AND added_via->>'source' = 'instance_group'",
- is_admin,
- is_operator,
- &instance_group_source,
- &ws.workspace_id,
- &email
- )
- .execute(&mut *tx)
- .await?;
- }
- }
-
// Apply instance-level role from group membership
let effective_role = compute_effective_instance_role(&email, &mut tx).await?;
apply_instance_role(&email, effective_role.as_deref(), &mut tx).await?;
+ // Sync workspace membership derived from this instance group.
+ #[cfg(feature = "private")]
+ {
+ use windmill_api_workspaces::workspaces_ee::reconcile_workspace_instance_groups;
+ reconcile_workspace_instance_groups(&affected_workspaces, &mut tx, &authed).await?;
+ }
+
tx.commit().await?;
Ok(format!("Added {} to igroup {}", email, name))
}
@@ -1100,12 +1192,22 @@ async fn remove_user_igroup(
require_super_admin(&db, &authed.email).await?;
let mut tx = db.begin().await?;
- let group_opt = sqlx::query_scalar!("SELECT name FROM instance_group WHERE name = $1", name,)
- .fetch_optional(&mut *tx)
- .await?;
+ // FOR UPDATE: the group row is the group-level mutex, taken before the workspace
+ // advisory locks (see reconcile_workspace_instance_groups).
+ let group_opt = sqlx::query_scalar!(
+ "SELECT name FROM instance_group WHERE name = $1 FOR UPDATE",
+ name,
+ )
+ .fetch_optional(&mut *tx)
+ .await?;
not_found_if_none(group_opt, "IGroup", &name)?;
+ // Before the membership delete's row lock.
+ #[cfg(feature = "private")]
+ let affected_workspaces =
+ lock_workspaces_referencing_instance_groups(std::slice::from_ref(&name), &mut tx).await?;
+
sqlx::query!(
"DELETE FROM email_to_igroup WHERE email = $1 AND igroup = $2",
email,
@@ -1125,17 +1227,19 @@ async fn remove_user_igroup(
)
.await?;
- // Remove user from workspaces where they were added via this instance group
- #[cfg(all(feature = "private", feature = "enterprise"))]
- {
- use windmill_api_workspaces::workspaces_ee::remove_users_from_instance_group_workspaces;
- remove_users_from_instance_group_workspaces(&email, &name, &mut tx).await?;
- }
-
// Recompute instance-level role after group removal
let effective_role = compute_effective_instance_role(&email, &mut tx).await?;
apply_instance_role(&email, effective_role.as_deref(), &mut tx).await?;
+ // Re-derive workspace membership now that the base tables reflect the removal: drops the
+ // user where this group was their only access source, or re-roles them from the groups
+ // they still belong to.
+ #[cfg(feature = "private")]
+ {
+ use windmill_api_workspaces::workspaces_ee::reconcile_workspace_instance_groups;
+ reconcile_workspace_instance_groups(&affected_workspaces, &mut tx, &authed).await?;
+ }
+
tx.commit().await?;
Ok(format!("Removed {} from igroup {}", email, name))
}
@@ -1265,6 +1369,37 @@ async fn overwrite_igroups(
require_super_admin(&db, &authed.email).await?;
let mut tx = db.begin().await?;
+ // The import replaces the whole group catalog, so the whole-table lock is its
+ // group-mutex phase, taken first like every path's group locks (see
+ // reconcile_workspace_instance_groups). Per-row FOR UPDATE would miss rows committed
+ // after the scan, which the unqualified deletes below would then lock after the
+ // workspace locks — the inverted order. EXCLUSIVE conflicts with the writes and the
+ // FOR UPDATE of every other mutation path while leaving plain reads unblocked.
+ sqlx::query("LOCK TABLE instance_group IN EXCLUSIVE MODE")
+ .execute(&mut *tx)
+ .await?;
+
+ let imported_names: Vec = igroups.iter().map(|g| g.name.clone()).collect();
+ // NULL-safe and correct for an empty import: `name <> ALL('{}')` is true for every row.
+ let previous_names: Vec = sqlx::query_scalar!(
+ "SELECT name FROM instance_group WHERE name <> ALL($1)",
+ &imported_names
+ )
+ .fetch_all(&mut *tx)
+ .await?;
+
+ // Membership of retained groups is wiped and re-imported below, so workspaces referencing
+ // either side of the import may see their projection change. Captured and advisory-locked
+ // before the settings update strips the dropped groups from them.
+ #[cfg(feature = "private")]
+ let affected_workspaces = {
+ let mut all_names = previous_names.clone();
+ all_names.extend(imported_names.iter().cloned());
+ lock_workspaces_referencing_instance_groups(&all_names, &mut tx).await?
+ };
+
+ remove_instance_groups_from_workspace_settings(&previous_names, &mut tx).await?;
+
sqlx::query!("DELETE FROM email_to_igroup")
.execute(&mut *tx)
.await?;
@@ -1325,6 +1460,15 @@ async fn overwrite_igroups(
apply_instance_role(email, None, &mut tx).await?;
}
+ // Runs after the re-insert so the reconciler judges membership against the imported
+ // state: a member who moved from a dropped group to a retained one is re-roled in place
+ // instead of losing workspace access.
+ #[cfg(feature = "private")]
+ {
+ use windmill_api_workspaces::workspaces_ee::reconcile_workspace_instance_groups;
+ reconcile_workspace_instance_groups(&affected_workspaces, &mut tx, &authed).await?;
+ }
+
audit_log(
&mut *tx,
&authed,
diff --git a/backend/windmill-api-integration-tests/tests/assets.rs b/backend/windmill-api-integration-tests/tests/assets.rs
new file mode 100644
index 0000000000..e1e7645e52
--- /dev/null
+++ b/backend/windmill-api-integration-tests/tests/assets.rs
@@ -0,0 +1,248 @@
+use serde_json::{json, Value};
+use sqlx::{Pool, Postgres};
+use uuid::Uuid;
+use windmill_test_utils::*;
+
+fn client() -> reqwest::Client {
+ reqwest::Client::new()
+}
+
+fn bearer(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
+ builder.header("Authorization", format!("Bearer {token}"))
+}
+
+async fn insert_job(db: &Pool, parent: Option, tag: &str) -> anyhow::Result {
+ let id = Uuid::new_v4();
+ sqlx::query(
+ "INSERT INTO v2_job (id, workspace_id, tag, created_by, permissioned_as, \
+ permissioned_as_email, kind, parent_job, same_worker, visible_to_owner) \
+ VALUES ($1, 'test-workspace', $3, 'test-user', 'u/test-user', \
+ 'test@windmill.dev', 'script', $2, false, true)",
+ )
+ .bind(id)
+ .bind(parent)
+ .bind(tag)
+ .execute(db)
+ .await?;
+ Ok(id)
+}
+
+/// `access_type` is `None` for a detection that could not tell read from write
+/// — how a resource passed in a job's arguments is recorded.
+async fn insert_job_asset(
+ db: &Pool,
+ job: Uuid,
+ path: &str,
+ access_type: Option<&str>,
+) -> anyhow::Result<()> {
+ sqlx::query(
+ "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind) \
+ VALUES ('test-workspace', $1, 's3object', $2::text::asset_access_type, $3, 'job')",
+ )
+ .bind(path)
+ .bind(access_type)
+ .bind(job.to_string())
+ .execute(db)
+ .await?;
+ Ok(())
+}
+
+/// A run reports what its whole job tree touched: runtime detection records
+/// against the job that did the operation, which for a flow step or a
+/// workflow-as-code task is never the job the user opened.
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_list_run_assets_covers_child_jobs(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let ws = format!("http://localhost:{port}/api/w/test-workspace");
+
+ let parent = insert_job(&db, None, "other").await?;
+ let child = insert_job(&db, Some(parent), "other").await?;
+ let unrelated = insert_job(&db, None, "other").await?;
+
+ insert_job_asset(&db, parent, "/data/shared.json", Some("r")).await?;
+ insert_job_asset(&db, child, "/data/shared.json", Some("w")).await?;
+ insert_job_asset(&db, child, "/data/child_only.json", Some("w")).await?;
+ insert_job_asset(&db, parent, "/data/from_args.json", None).await?;
+ insert_job_asset(&db, child, "/data/from_args.json", Some("w")).await?;
+ insert_job_asset(&db, unrelated, "/data/unrelated.json", Some("w")).await?;
+
+ let resp = bearer(
+ client().get(format!("{ws}/jobs/run_assets/{parent}")),
+ "SECRET_TOKEN",
+ )
+ .send()
+ .await?;
+ assert_eq!(resp.status().as_u16(), 200);
+ let body: Value = resp.json().await?;
+ assert_eq!(body["truncated"], json!(false));
+ assert_eq!(
+ body["assets"],
+ json!([
+ { "path": "/data/child_only.json", "kind": "s3object", "access_type": "w" },
+ // The parent recorded no access type for this one; that must not erase
+ // the child's.
+ { "path": "/data/from_args.json", "kind": "s3object", "access_type": "w" },
+ { "path": "/data/shared.json", "kind": "s3object", "access_type": "rw" },
+ ]),
+ "parent should report its own and its child's assets, with access types merged"
+ );
+
+ let resp = bearer(
+ client().get(format!("{ws}/jobs/run_assets/{child}")),
+ "SECRET_TOKEN",
+ )
+ .send()
+ .await?;
+ assert_eq!(resp.status().as_u16(), 200);
+ let body: Value = resp.json().await?;
+ assert_eq!(
+ body["assets"],
+ json!([
+ { "path": "/data/child_only.json", "kind": "s3object", "access_type": "w" },
+ { "path": "/data/from_args.json", "kind": "s3object", "access_type": "w" },
+ { "path": "/data/shared.json", "kind": "s3object", "access_type": "w" },
+ ]),
+ "a child should report only what it touched itself"
+ );
+
+ // `asset` has no RLS of its own, so the job read gate is the only thing
+ // standing between another member and these paths.
+ let resp = bearer(
+ client().get(format!("{ws}/jobs/run_assets/{parent}")),
+ "SECRET_TOKEN_2",
+ )
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status().as_u16(),
+ 403,
+ "a member who cannot read the run must not read its assets"
+ );
+
+ // ...and a share link is what lets that same member in. The tree is walked
+ // outside the caller's RLS precisely so this works, since the token grants
+ // access their own permissions do not.
+ let view_token: String = bearer(
+ client().get(format!("{ws}/jobs/job_view_token/{parent}")),
+ "SECRET_TOKEN",
+ )
+ .send()
+ .await?
+ .text()
+ .await?;
+ let resp = bearer(
+ client().get(format!("{ws}/jobs/run_assets/{parent}")),
+ "SECRET_TOKEN_2",
+ )
+ .header("X-View-Token", view_token)
+ .send()
+ .await?;
+ assert_eq!(resp.status().as_u16(), 200);
+ let body: Value = resp.json().await?;
+ assert_eq!(
+ body["assets"].as_array().map(|a| a.len()),
+ Some(3),
+ "a share-link viewer should see the whole tree's assets"
+ );
+
+ Ok(())
+}
+
+/// The read gate only checks the root job's tag, so the walk has to keep a
+/// tag-scoped token out of descendants outside its scope — without hiding the
+/// ones below them, which the token could have asked for directly.
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_list_run_assets_scopes_descendants_by_tag(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let ws = format!("http://localhost:{port}/api/w/test-workspace");
+
+ sqlx::query(
+ "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) \
+ VALUES (encode(sha256('TAG_TOKEN'::bytea), 'hex'), 'TAG_TOK', 'TAG_TOKEN', \
+ 'test@windmill.dev', 'tag-only', true, ARRAY['if_jobs:filter_tags:other'])",
+ )
+ .execute(&db)
+ .await?;
+
+ let parent = insert_job(&db, None, "other").await?;
+ let in_scope = insert_job(&db, Some(parent), "other").await?;
+ let out_of_scope = insert_job(&db, Some(parent), "deno").await?;
+ let below_out_of_scope = insert_job(&db, Some(out_of_scope), "other").await?;
+ insert_job_asset(&db, in_scope, "/data/in_scope.json", Some("w")).await?;
+ insert_job_asset(&db, out_of_scope, "/data/out_of_scope.json", Some("w")).await?;
+ insert_job_asset(&db, below_out_of_scope, "/data/nested.json", Some("w")).await?;
+
+ let resp = bearer(
+ client().get(format!("{ws}/jobs/run_assets/{parent}")),
+ "TAG_TOKEN",
+ )
+ .send()
+ .await?;
+ assert_eq!(resp.status().as_u16(), 200);
+ let body: Value = resp.json().await?;
+ assert_eq!(
+ body["assets"],
+ json!([
+ { "path": "/data/in_scope.json", "kind": "s3object", "access_type": "w" },
+ { "path": "/data/nested.json", "kind": "s3object", "access_type": "w" },
+ ]),
+ "a tag-scoped token must not read assets of descendants outside its tags, \
+ but must still reach in-scope jobs below them"
+ );
+
+ Ok(())
+}
+
+/// A fan-out run can touch more assets than one response should carry, and the
+/// cut must be reported rather than served as if it were the whole list.
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_list_run_assets_caps_the_list(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let ws = format!("http://localhost:{port}/api/w/test-workspace");
+
+ // Three child jobs touching the same 1200 assets: the cap counts assets, and
+ // the retention allows ten job rows per asset, so a row-counted cap would cut
+ // this at a third of the list.
+ let parent = insert_job(&db, None, "other").await?;
+ for _ in 0..3 {
+ let child = insert_job(&db, Some(parent), "other").await?;
+ sqlx::query(
+ "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind) \
+ SELECT 'test-workspace', '/out/' || lpad(g::text, 6, '0') || '.json', 's3object', 'w', \
+ $1, 'job' FROM generate_series(1, 1200) g",
+ )
+ .bind(child.to_string())
+ .execute(&db)
+ .await?;
+ }
+
+ let resp = bearer(
+ client().get(format!("{ws}/jobs/run_assets/{parent}")),
+ "SECRET_TOKEN",
+ )
+ .send()
+ .await?;
+ assert_eq!(resp.status().as_u16(), 200);
+ let body: Value = resp.json().await?;
+ assert_eq!(body["truncated"], json!(true));
+ let assets = body["assets"].as_array().expect("assets array");
+ assert_eq!(assets.len(), 1000);
+ assert_eq!(
+ assets[0],
+ json!({ "path": "/out/000001.json", "kind": "s3object", "access_type": "w" }),
+ "the cap keeps the head of the ordered list, with its access type merged"
+ );
+ // Three jobs touched each asset, so a cap counting rows rather than assets
+ // would fill the list with repeats and stop around /out/000334.json.
+ assert_eq!(
+ assets[999]["path"], "/out/001000.json",
+ "the cap counts assets, not asset rows"
+ );
+ Ok(())
+}
diff --git a/backend/windmill-api-integration-tests/tests/fixtures/resources_test.sql b/backend/windmill-api-integration-tests/tests/fixtures/resources_test.sql
index 1e04195ae0..2f101662fe 100644
--- a/backend/windmill-api-integration-tests/tests/fixtures/resources_test.sql
+++ b/backend/windmill-api-integration-tests/tests/fixtures/resources_test.sql
@@ -69,6 +69,12 @@ VALUES ('test-workspace', 'u/test-user/fileset_resource',
'{"config.yaml": "key: value", "data/input.json": "{\"items\": []}"}',
'A fileset resource', 'test_fileset', '{}', 'test-user');
+-- === list_search value cap test data ===
+
+INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by)
+VALUES ('test-workspace', 'u/test-user/oversized_resource', jsonb_build_object('big', repeat('x', 5000)),
+ 'Larger than the list_search cap', 'object', '{}', 'test-user');
+
-- === mcp_tools test data ===
INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by)
diff --git a/backend/windmill-api-integration-tests/tests/groups.rs b/backend/windmill-api-integration-tests/tests/groups.rs
index 0fd8ee7e08..f86522aae3 100644
--- a/backend/windmill-api-integration-tests/tests/groups.rs
+++ b/backend/windmill-api-integration-tests/tests/groups.rs
@@ -159,12 +159,7 @@ async fn test_group_endpoints(db: Pool) -> anyhow::Result<()> {
.send()
.await
.unwrap();
- assert_eq!(
- resp.status(),
- 200,
- "create igroup: {}",
- resp.text().await?
- );
+ assert_eq!(resp.status(), 200, "create igroup: {}", resp.text().await?);
// --- list instance groups ---
let resp = authed(client().get(format!("{global_base}/list")))
@@ -199,12 +194,7 @@ async fn test_group_endpoints(db: Pool) -> anyhow::Result<()> {
.send()
.await
.unwrap();
- assert_eq!(
- resp.status(),
- 200,
- "update igroup: {}",
- resp.text().await?
- );
+ assert_eq!(resp.status(), 200, "update igroup: {}", resp.text().await?);
// verify update
let resp = authed(client().get(format!("{global_base}/get/test_igroup")))
@@ -220,12 +210,7 @@ async fn test_group_endpoints(db: Pool) -> anyhow::Result<()> {
.send()
.await
.unwrap();
- assert_eq!(
- resp.status(),
- 200,
- "adduser igroup: {}",
- resp.text().await?
- );
+ assert_eq!(resp.status(), 200, "adduser igroup: {}", resp.text().await?);
// verify membership
let resp = authed(client().get(format!("{global_base}/get/test_igroup")))
@@ -243,13 +228,11 @@ async fn test_group_endpoints(db: Pool) -> anyhow::Result<()> {
);
// --- removeuser from instance group ---
- let resp = authed(client().post(format!(
- "{global_base}/removeuser/test_igroup"
- )))
- .json(&json!({"email": "test@windmill.dev"}))
- .send()
- .await
- .unwrap();
+ let resp = authed(client().post(format!("{global_base}/removeuser/test_igroup")))
+ .json(&json!({"email": "test@windmill.dev"}))
+ .send()
+ .await
+ .unwrap();
assert_eq!(resp.status(), 200);
// --- export (EE-gated) ---
@@ -280,12 +263,7 @@ async fn test_group_endpoints(db: Pool) -> anyhow::Result<()> {
.send()
.await
.unwrap();
- assert_eq!(
- resp.status(),
- 200,
- "delete igroup: {}",
- resp.text().await?
- );
+ assert_eq!(resp.status(), 200, "delete igroup: {}", resp.text().await?);
// verify deleted
let resp = authed(client().get(format!("{global_base}/list")))
@@ -297,3 +275,641 @@ async fn test_group_endpoints(db: Pool) -> anyhow::Result<()> {
Ok(())
}
+
+/// Deleting an instance group must not revoke workspace access a member still holds through
+/// another configured group.
+///
+/// `added_via.group` records only the member's highest-precedence group, so any cleanup keyed
+/// on that field alone evicts members who still qualify via a lower-precedence one. Membership
+/// must be re-derived from all the groups the workspace still references.
+#[cfg(feature = "private")]
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_delete_instance_group_preserves_access_via_other_group(
+ db: Pool,
+) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let global_base = format!("http://localhost:{port}/api/groups");
+ let ws_base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
+
+ for g in ["igroup_a", "igroup_b"] {
+ let resp = authed(client().post(format!("{global_base}/create")))
+ .json(&json!({ "name": g, "summary": g }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "create {g}");
+ }
+
+ // multi@ belongs to both groups; only_a@ only to the group that gets deleted.
+ for (g, email) in [
+ ("igroup_a", "multi@example.com"),
+ ("igroup_b", "multi@example.com"),
+ ("igroup_a", "only_a@example.com"),
+ ] {
+ let resp = authed(client().post(format!("{global_base}/adduser/{g}")))
+ .json(&json!({ "email": email }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "adduser {g}/{email}");
+ }
+
+ // igroup_a grants the higher-precedence role, so added_via lands on it.
+ let resp = authed(client().post(format!("{ws_base}/edit_instance_groups")))
+ .json(&json!({
+ "groups": ["igroup_a", "igroup_b"],
+ "roles": { "igroup_a": "admin", "igroup_b": "developer" }
+ }))
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 200,
+ "edit_instance_groups: {}",
+ resp.text().await?
+ );
+
+ let (is_admin, via): (bool, Option) = sqlx::query_as(
+ "SELECT is_admin, added_via->>'group' FROM usr
+ WHERE workspace_id = 'test-workspace' AND email = 'multi@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert!(is_admin, "multi@ should start as admin via igroup_a");
+ assert_eq!(via.as_deref(), Some("igroup_a"));
+
+ // Workspace state that must survive the group removal. `delete_workspace_user_internal`
+ // drops all of this, so a delete-and-re-add of a still-qualifying member loses it silently.
+ let username: String = sqlx::query_scalar(
+ "SELECT username FROM usr WHERE workspace_id = 'test-workspace' AND email = 'multi@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ sqlx::query(
+ "INSERT INTO favorite (workspace_id, usr, path, favorite_kind)
+ VALUES ('test-workspace', $1, 'f/keep/me', 'script')",
+ )
+ .bind(&username)
+ .execute(&db)
+ .await?;
+ sqlx::query(
+ "INSERT INTO draft (workspace_id, path, typ, value)
+ VALUES ('test-workspace', 'u/' || $1 || '/keep', 'script', '{}'::jsonb)",
+ )
+ .bind(&username)
+ .execute(&db)
+ .await?;
+
+ let resp = authed(client().delete(format!("{global_base}/delete/igroup_a")))
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 200,
+ "delete igroup_a: {}",
+ resp.text().await?
+ );
+
+ // Still a member, downgraded to igroup_b's role rather than evicted.
+ let (is_admin, is_operator, via): (bool, bool, Option) = sqlx::query_as(
+ "SELECT is_admin, operator, added_via->>'group' FROM usr
+ WHERE workspace_id = 'test-workspace' AND email = 'multi@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert!(!is_admin, "multi@ should lose admin with igroup_a gone");
+ assert!(!is_operator, "igroup_b grants developer, not operator");
+ assert_eq!(
+ via.as_deref(),
+ Some("igroup_b"),
+ "added_via should re-point at the surviving group"
+ );
+
+ // Their workspace state is intact: they were never deleted and re-added.
+ let favorites: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM favorite WHERE workspace_id = 'test-workspace' AND path = 'f/keep/me'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ favorites, 1,
+ "favorite must survive losing a non-sole group"
+ );
+ let drafts: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM draft WHERE workspace_id = 'test-workspace' AND path LIKE 'u/%/keep'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(drafts, 1, "draft must survive losing a non-sole group");
+
+ // igroup_a was only_a@'s sole path in, so they are removed.
+ let remaining: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM usr
+ WHERE workspace_id = 'test-workspace' AND email = 'only_a@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(remaining, 0, "only_a@ should be removed with igroup_a");
+
+ // The deleted group leaves no dangling reference in either auto_invite field.
+ let (groups, roles): (serde_json::Value, serde_json::Value) = sqlx::query_as(
+ "SELECT auto_invite->'instance_groups', auto_invite->'instance_groups_roles'
+ FROM workspace_settings WHERE workspace_id = 'test-workspace'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(groups, json!(["igroup_b"]), "igroup_a should be stripped");
+ assert_eq!(
+ roles,
+ json!({ "igroup_b": "developer" }),
+ "igroup_a's role entry should be stripped"
+ );
+
+ Ok(())
+}
+
+/// Removing a member from one instance group must re-derive their role from the groups they
+/// still belong to, not leave the privileges the removed group granted.
+///
+/// Still-qualifying members keep their `usr` row (deleting it would destroy their workspace
+/// data), so the removal path must recompute that row's role — otherwise a member dropped
+/// from an admin group keeps `is_admin` through the stale row.
+#[cfg(feature = "private")]
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_remove_user_from_instance_group_rederives_role(
+ db: Pool,
+) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let global_base = format!("http://localhost:{port}/api/groups");
+ let ws_base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
+
+ for g in ["role_a", "role_b"] {
+ let resp = authed(client().post(format!("{global_base}/create")))
+ .json(&json!({ "name": g }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "create {g}");
+ let resp = authed(client().post(format!("{global_base}/adduser/{g}")))
+ .json(&json!({ "email": "demoted@example.com" }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "adduser {g}");
+ }
+
+ let resp = authed(client().post(format!("{ws_base}/edit_instance_groups")))
+ .json(&json!({
+ "groups": ["role_a", "role_b"],
+ "roles": { "role_a": "admin", "role_b": "developer" }
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "edit: {}", resp.text().await?);
+
+ let is_admin: bool = sqlx::query_scalar(
+ "SELECT is_admin FROM usr WHERE workspace_id = 'test-workspace' AND email = 'demoted@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert!(is_admin, "should start admin via role_a");
+
+ // Drop them from the admin group only.
+ let resp = authed(client().post(format!("{global_base}/removeuser/role_a")))
+ .json(&json!({ "email": "demoted@example.com" }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "removeuser: {}", resp.text().await?);
+
+ let (is_admin, is_operator, via): (bool, bool, Option) = sqlx::query_as(
+ "SELECT is_admin, operator, added_via->>'group' FROM usr
+ WHERE workspace_id = 'test-workspace' AND email = 'demoted@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert!(
+ !is_admin,
+ "admin granted by role_a must not survive removal from role_a"
+ );
+ assert!(!is_operator, "role_b grants developer");
+ assert_eq!(via.as_deref(), Some("role_b"));
+
+ Ok(())
+}
+
+/// An overwrite import that moves a member from a dropped group to a retained one must keep
+/// their workspace data.
+///
+/// Qualification must be judged against the imported membership, not the pre-import state:
+/// judged too early, the member's new group is not yet visible, they are deleted, and any
+/// re-add creates a fresh row stripped of everything workspace-scoped.
+#[cfg(all(feature = "private", feature = "enterprise"))]
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_overwrite_igroups_preserves_moved_member_data(
+ db: Pool,
+) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let global_base = format!("http://localhost:{port}/api/groups");
+ let ws_base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
+
+ for g in ["move_from", "move_to"] {
+ let resp = authed(client().post(format!("{global_base}/create")))
+ .json(&json!({ "name": g }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "create {g}");
+ }
+ // Member starts only in move_from.
+ let resp = authed(client().post(format!("{global_base}/adduser/move_from")))
+ .json(&json!({ "email": "mover@example.com" }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200);
+
+ let resp = authed(client().post(format!("{ws_base}/edit_instance_groups")))
+ .json(&json!({
+ "groups": ["move_from", "move_to"],
+ "roles": { "move_from": "developer", "move_to": "developer" }
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "edit: {}", resp.text().await?);
+
+ let username: String = sqlx::query_scalar(
+ "SELECT username FROM usr WHERE workspace_id = 'test-workspace' AND email = 'mover@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ sqlx::query(
+ "INSERT INTO favorite (workspace_id, usr, path, favorite_kind)
+ VALUES ('test-workspace', $1, 'f/moved/keep', 'script')",
+ )
+ .bind(&username)
+ .execute(&db)
+ .await?;
+
+ // Import drops move_from entirely and puts the member in move_to instead.
+ let resp = authed(client().post(format!("{global_base}/overwrite")))
+ .json(&json!([
+ { "name": "move_to", "emails": ["mover@example.com"] }
+ ]))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "overwrite: {}", resp.text().await?);
+
+ let remaining: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM usr WHERE workspace_id = 'test-workspace' AND email = 'mover@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ remaining, 1,
+ "member should still be in the workspace via move_to"
+ );
+
+ let favorites: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM favorite WHERE workspace_id = 'test-workspace' AND path = 'f/moved/keep'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ favorites, 1,
+ "moving between groups in one import must not destroy workspace data"
+ );
+
+ Ok(())
+}
+
+/// A full-import overwrite must reconcile the membership of retained groups too: a member
+/// dropped from a retained group loses the access that group granted, and a member who only
+/// lost their highest-precedence group is re-roled in place instead of keeping a stale
+/// elevated role.
+///
+/// Regression: the delta-based cleanup only acted on groups that disappeared from the import,
+/// so an import that kept a group but dropped some of its members never cleaned those members
+/// up.
+#[cfg(all(feature = "private", feature = "enterprise"))]
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_overwrite_igroups_reconciles_retained_group_membership(
+ db: Pool,
+) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let global_base = format!("http://localhost:{port}/api/groups");
+ let ws_base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
+
+ for g in ["top_admins", "base_devs"] {
+ let resp = authed(client().post(format!("{global_base}/create")))
+ .json(&json!({ "name": g }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "create {g}");
+ }
+
+ // demoted@ holds admin via top_admins and developer via base_devs; dropped@ only has
+ // base_devs.
+ for (g, email) in [
+ ("top_admins", "demoted@example.com"),
+ ("base_devs", "demoted@example.com"),
+ ("base_devs", "dropped@example.com"),
+ ] {
+ let resp = authed(client().post(format!("{global_base}/adduser/{g}")))
+ .json(&json!({ "email": email }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "adduser {g}/{email}");
+ }
+
+ let resp = authed(client().post(format!("{ws_base}/edit_instance_groups")))
+ .json(&json!({
+ "groups": ["top_admins", "base_devs"],
+ "roles": { "top_admins": "admin", "base_devs": "developer" }
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "edit: {}", resp.text().await?);
+
+ let (is_admin, via): (bool, Option) = sqlx::query_as(
+ "SELECT is_admin, added_via->>'group' FROM usr
+ WHERE workspace_id = 'test-workspace' AND email = 'demoted@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert!(is_admin, "demoted@ should start as admin via top_admins");
+ assert_eq!(via.as_deref(), Some("top_admins"));
+
+ // Workspace state that must survive the demotion.
+ let username: String = sqlx::query_scalar(
+ "SELECT username FROM usr WHERE workspace_id = 'test-workspace' AND email = 'demoted@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ sqlx::query(
+ "INSERT INTO favorite (workspace_id, usr, path, favorite_kind)
+ VALUES ('test-workspace', $1, 'f/lifecycle/keep', 'script')",
+ )
+ .bind(&username)
+ .execute(&db)
+ .await?;
+
+ // The import retains both groups but drops demoted@ from top_admins and dropped@ from
+ // base_devs.
+ let resp = authed(client().post(format!("{global_base}/overwrite")))
+ .json(&json!([
+ { "name": "top_admins", "emails": [] },
+ { "name": "base_devs", "emails": ["demoted@example.com"] }
+ ]))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "overwrite: {}", resp.text().await?);
+
+ // demoted@ stays, re-roled to base_devs' developer, with their data intact.
+ let (is_admin, is_operator, via): (bool, bool, Option) = sqlx::query_as(
+ "SELECT is_admin, operator, added_via->>'group' FROM usr
+ WHERE workspace_id = 'test-workspace' AND email = 'demoted@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert!(
+ !is_admin,
+ "admin from top_admins must not survive being dropped from it"
+ );
+ assert!(!is_operator, "base_devs grants developer");
+ assert_eq!(via.as_deref(), Some("base_devs"));
+
+ let favorites: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM favorite WHERE workspace_id = 'test-workspace' AND path = 'f/lifecycle/keep'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ favorites, 1,
+ "re-roling in place must not destroy workspace data"
+ );
+
+ // dropped@ lost their only configured group even though the group itself was retained.
+ let remaining: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM usr
+ WHERE workspace_id = 'test-workspace' AND email = 'dropped@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ remaining, 0,
+ "member dropped from a retained group must be removed"
+ );
+
+ // Both groups were retained, so the workspace config is untouched.
+ let (groups, roles): (serde_json::Value, serde_json::Value) = sqlx::query_as(
+ "SELECT auto_invite->'instance_groups', auto_invite->'instance_groups_roles'
+ FROM workspace_settings WHERE workspace_id = 'test-workspace'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(groups, json!(["top_admins", "base_devs"]));
+ assert_eq!(
+ roles,
+ json!({ "top_admins": "admin", "base_devs": "developer" })
+ );
+
+ Ok(())
+}
+
+/// Members whose `added_via` source is not 'instance_group' — manually added users, and the
+/// orphaned members the `preserve_orphaned_instance_group_members` migration converted to
+/// manual — are invisible to reconciliation: never re-roled and never removed, even when they
+/// also appear in a configured group's membership.
+#[cfg(feature = "private")]
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_reconcile_ignores_non_instance_group_members(
+ db: Pool,
+) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let global_base = format!("http://localhost:{port}/api/groups");
+ let ws_base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
+
+ let resp = authed(client().post(format!("{global_base}/create")))
+ .json(&json!({ "name": "visible_grp" }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "create");
+ for email in ["kept@example.com", "shielded@example.com"] {
+ let resp = authed(client().post(format!("{global_base}/adduser/visible_grp")))
+ .json(&json!({ "email": email }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "adduser {email}");
+ }
+
+ // shielded@ is already in the workspace through a non-instance_group source (the shape
+ // the migration leaves behind), at a role the group config would not grant. The username
+ // deliberately differs from the instance-derived one ('shielded'): an unguarded
+ // auto_add_user would then insert a second usr row for the email instead of no-op'ing on
+ // a username conflict, so the count assertions below can catch it.
+ sqlx::query(
+ r#"INSERT INTO usr (workspace_id, username, email, is_admin, operator, added_via)
+ VALUES ('test-workspace', 'shielded_legacy', 'shielded@example.com', true, false,
+ '{"source": "manual", "migrated_from_instance_group": "gone_grp"}'::jsonb)"#,
+ )
+ .execute(&db)
+ .await?;
+
+ let resp = authed(client().post(format!("{ws_base}/edit_instance_groups")))
+ .json(&json!({
+ "groups": ["visible_grp"],
+ "roles": { "visible_grp": "developer" }
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "edit: {}", resp.text().await?);
+
+ // kept@ was auto-added via the group; shielded@ kept their single manual row untouched.
+ let kept: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM usr WHERE workspace_id = 'test-workspace' AND email = 'kept@example.com'
+ AND added_via->>'source' = 'instance_group'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(kept, 1, "group member should be auto-added");
+
+ let shielded_rows: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM usr WHERE workspace_id = 'test-workspace' AND email = 'shielded@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ shielded_rows, 1,
+ "reconciliation must not create a second usr row for a member already present under a non-instance_group source"
+ );
+
+ // Dropping both users from the group removes the instance_group-sourced member but must
+ // leave the manual row alone.
+ for email in ["kept@example.com", "shielded@example.com"] {
+ let resp = authed(client().post(format!("{global_base}/removeuser/visible_grp")))
+ .json(&json!({ "email": email }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "removeuser {email}");
+ }
+
+ let kept: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM usr WHERE workspace_id = 'test-workspace' AND email = 'kept@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ kept, 0,
+ "instance_group-sourced member loses access with their only group"
+ );
+
+ let (username, is_admin, via_source): (String, bool, Option) = sqlx::query_as(
+ "SELECT username, is_admin, added_via->>'source' FROM usr
+ WHERE workspace_id = 'test-workspace' AND email = 'shielded@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ username, "shielded_legacy",
+ "the original manual row must be the only one"
+ );
+ assert!(
+ is_admin,
+ "manual member's role must not be touched by reconciliation"
+ );
+ assert_eq!(via_source.as_deref(), Some("manual"));
+
+ Ok(())
+}
+
+/// The upgrade migration converts every member the reconciler would evict — those whose
+/// granting group was deleted and those dropped from a group that still exists — and leaves
+/// still-qualifying members alone. The migration has already run against the empty test
+/// database by the time this executes, so the test fabricates pre-fix state and re-executes
+/// the migration's statements, which are idempotent plain UPDATEs.
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_preserve_orphaned_members_migration(db: Pool) -> anyhow::Result<()> {
+ // ghost_grp pins the statement order: it is referenced by the workspace and still has a
+ // membership row, but no instance_group row. Only when the reference strip runs before
+ // the conversion does ghost@ read as unconverted-by-membership nowhere and get preserved;
+ // converting first would spare them on the doomed reference and then strand them.
+ sqlx::raw_sql(
+ r#"
+ INSERT INTO workspace (id, name, owner) VALUES ('mig-ws', 'mig-ws', 'admin@windmill.dev');
+ INSERT INTO workspace_settings (workspace_id, auto_invite) VALUES
+ ('mig-ws', '{"instance_groups": ["gone_grp", "ghost_grp", "live_grp"], "instance_groups_roles": {"gone_grp": "admin", "ghost_grp": "developer", "live_grp": "developer"}}'::jsonb);
+ INSERT INTO instance_group (name) VALUES ('live_grp');
+ INSERT INTO email_to_igroup (email, igroup) VALUES
+ ('live@example.com', 'live_grp'),
+ ('ghost@example.com', 'ghost_grp');
+ INSERT INTO usr (workspace_id, username, email, is_admin, operator, added_via) VALUES
+ ('mig-ws', 'orphan', 'orphan@example.com', true, false, '{"source": "instance_group", "group": "gone_grp"}'::jsonb),
+ ('mig-ws', 'droppedu', 'dropped@example.com', false, false, '{"source": "instance_group", "group": "live_grp"}'::jsonb),
+ ('mig-ws', 'ghostmember', 'ghost@example.com', false, false, '{"source": "instance_group", "group": "ghost_grp"}'::jsonb),
+ ('mig-ws', 'livemember', 'live@example.com', false, false, '{"source": "instance_group", "group": "live_grp"}'::jsonb);
+ "#,
+ )
+ .execute(&db)
+ .await?;
+
+ sqlx::raw_sql(include_str!(
+ "../../migrations/20260813195023_preserve_orphaned_instance_group_members.up.sql"
+ ))
+ .execute(&db)
+ .await?;
+
+ // Deleted-group orphan and retained-group-dropped orphan both become manual members
+ // with the original group recorded; the still-qualifying member is untouched.
+ for (email, expected_group) in [
+ ("orphan@example.com", "gone_grp"),
+ ("dropped@example.com", "live_grp"),
+ ("ghost@example.com", "ghost_grp"),
+ ] {
+ let (source, migrated_from): (Option, Option) = sqlx::query_as(
+ "SELECT added_via->>'source', added_via->>'migrated_from_instance_group'
+ FROM usr WHERE workspace_id = 'mig-ws' AND email = $1",
+ )
+ .bind(email)
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ source.as_deref(),
+ Some("manual"),
+ "{email} should be converted"
+ );
+ assert_eq!(
+ migrated_from.as_deref(),
+ Some(expected_group),
+ "{email} marker"
+ );
+ }
+
+ let (source, group): (Option, Option) = sqlx::query_as(
+ "SELECT added_via->>'source', added_via->>'group'
+ FROM usr WHERE workspace_id = 'mig-ws' AND email = 'live@example.com'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ source.as_deref(),
+ Some("instance_group"),
+ "still-qualifying member spared"
+ );
+ assert_eq!(group.as_deref(), Some("live_grp"));
+
+ // The dangling references are stripped from both auto_invite fields; the live one stays.
+ let (groups, roles): (serde_json::Value, serde_json::Value) = sqlx::query_as(
+ "SELECT auto_invite->'instance_groups', auto_invite->'instance_groups_roles'
+ FROM workspace_settings WHERE workspace_id = 'mig-ws'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(groups, json!(["live_grp"]));
+ assert_eq!(roles, json!({ "live_grp": "developer" }));
+
+ Ok(())
+}
diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs
index 07afbb3442..3f0b21b184 100644
--- a/backend/windmill-api-integration-tests/tests/resources.rs
+++ b/backend/windmill-api-integration-tests/tests/resources.rs
@@ -199,6 +199,25 @@ async fn test_resource_endpoints(db: Pool) -> anyhow::Result<()> {
let list = resp.json::>().await?;
assert!(!list.is_empty());
+ // Values are capped so the search modal never has to hold a whole workspace of
+ // resource content in memory.
+ let find = |path: &str| {
+ list.iter()
+ .find(|r| r["path"] == path)
+ .unwrap_or_else(|| panic!("{path} missing from list_search"))
+ .clone()
+ };
+ let oversized = find("u/test-user/oversized_resource");
+ assert_eq!(oversized["value"].as_str().unwrap().chars().count(), 4000);
+ assert_eq!(oversized["truncated"], true);
+
+ let simple = find("u/test-user/simple_resource");
+ assert!(simple["value"].as_str().unwrap().contains("\"host\""));
+ assert_eq!(simple["truncated"], false);
+
+ // A null value must still come back as searchable text, not null.
+ assert_eq!(find("u/test-user/null_resource")["value"], "");
+
// --- list_names ---
let resp = authed(client().get(format!("{base}/list_names/object")))
.send()
diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs
index b4e9bf269a..ec790ec060 100644
--- a/backend/windmill-api-schedule/src/lib.rs
+++ b/backend/windmill-api-schedule/src/lib.rs
@@ -27,6 +27,9 @@ use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
schedule::Schedule,
+ trigger_history::{
+ self, TriggerHistoryEvent, TriggerOperation, TriggerSource, SCHEDULE_TRIGGER_KIND,
+ },
user_drafts::{
delete_all_drafts_for_path, fetch_draft_only_list_rows, overlay_or_draft_only,
UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
@@ -85,6 +88,40 @@ fn resolve_edited_by(authed: &ApiAuthed) -> String {
authed.username.clone()
}
+/// Append this mutation to `trigger_history`, diffing the row against `before`.
+///
+/// Call it on the transaction that made the change, after the change: the
+/// snapshot it takes is the "after" side of the diff, and the two commit or roll
+/// back together.
+async fn record_schedule_history(
+ tx: &mut sqlx::PgConnection,
+ authed: &ApiAuthed,
+ w_id: &str,
+ path: &str,
+ operation: TriggerOperation,
+ before: Option,
+) -> Result<()> {
+ let after = trigger_history::snapshot_row(&mut *tx, "schedule", w_id, path).await?;
+ // Nothing to describe when the row is not there after the write: the same
+ // guard the trigger side needs, kept here so the two read alike.
+ if after.is_none() {
+ return Ok(());
+ }
+ trigger_history::record(
+ &mut *tx,
+ TriggerHistoryEvent {
+ workspace_id: w_id,
+ trigger_kind: SCHEDULE_TRIGGER_KIND,
+ path,
+ operation,
+ source: TriggerSource::of_request(authed.is_session_token),
+ username: Some(&authed.username),
+ changes: trigger_history::summarize_changes(before.as_ref(), after.as_ref()),
+ },
+ )
+ .await
+}
+
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_schedule))
@@ -417,6 +454,16 @@ async fn create_schedule(
.await
.map_err(|e| Error::internal_err(format!("inserting schedule in {w_id}: {e:#}")))?;
+ record_schedule_history(
+ &mut *tx,
+ &authed,
+ &w_id,
+ &ns.path,
+ TriggerOperation::Create,
+ None,
+ )
+ .await?;
+
audit_log(
&mut *tx,
&authed,
@@ -524,6 +571,8 @@ async fn edit_schedule(
authed.email.clone()
};
+ let before = trigger_history::snapshot_row(&mut *tx, "schedule", &w_id, path).await?;
+
let schedule = sqlx::query_as!(
Schedule,
r#"
@@ -632,6 +681,16 @@ async fn edit_schedule(
// like set_enabled, flow updates, and worker job completions.
clear_schedule(&mut tx, path, &w_id).await?;
+ record_schedule_history(
+ &mut *tx,
+ &authed,
+ &w_id,
+ path,
+ TriggerOperation::Update,
+ before,
+ )
+ .await?;
+
audit_log(
&mut *tx,
&authed,
@@ -1084,6 +1143,8 @@ pub async fn set_enabled(
}
}
}
+ let before = trigger_history::snapshot_row(&mut *tx, "schedule", &w_id, path).await?;
+
// email is still written for backwards compat with old workers that don't know about permissioned_as
let schedule_o = sqlx::query_as!(
Schedule,
@@ -1139,6 +1200,20 @@ pub async fn set_enabled(
clear_schedule(&mut tx, path, &w_id).await?;
+ record_schedule_history(
+ &mut *tx,
+ &authed,
+ &w_id,
+ path,
+ if payload.enabled {
+ TriggerOperation::Enable
+ } else {
+ TriggerOperation::Disable
+ },
+ before,
+ )
+ .await?;
+
audit_log(
&mut *tx,
&authed,
@@ -1285,6 +1360,22 @@ async fn delete_schedule(
.await?;
}
+ // No diff: the row is gone, and the trashbin above already keeps its full
+ // contents for a restore.
+ trigger_history::record(
+ &mut *tx,
+ TriggerHistoryEvent {
+ workspace_id: &w_id,
+ trigger_kind: SCHEDULE_TRIGGER_KIND,
+ path,
+ operation: TriggerOperation::Delete,
+ source: TriggerSource::of_request(authed.is_session_token),
+ username: Some(&authed.username),
+ changes: None,
+ },
+ )
+ .await?;
+
audit_log(
&mut *tx,
&authed,
@@ -1373,6 +1464,11 @@ async fn set_default_error_handler(
}
if payload.override_existing {
+ // The rewrite and its history rows go in one transaction: on separate
+ // connections a concurrent edit could interleave, leaving the
+ // id-ordered drawer showing the wrong latest change, and a failed
+ // insert would leave the schedules rewritten with nothing recording it.
+ let mut tx = db.begin().await?;
let updated_schedules: Vec;
match payload.handler_type {
HandlerType::Error => {
@@ -1386,14 +1482,14 @@ async fn set_default_error_handler(
payload.number_of_occurence_exact,
w_id,
)
- .fetch_all(&db)
+ .fetch_all(&mut *tx)
.await?;
} else {
updated_schedules = sqlx::query_scalar!(
"UPDATE schedule SET ws_error_handler_muted = false, on_failure = NULL, on_failure_extra_args = NULL, on_failure_times = NULL, on_failure_exact = NULL WHERE workspace_id = $1 RETURNING path",
w_id,
)
- .fetch_all(&db)
+ .fetch_all(&mut *tx)
.await?;
}
}
@@ -1406,14 +1502,14 @@ async fn set_default_error_handler(
payload.number_of_occurence,
w_id,
)
- .fetch_all(&db)
+ .fetch_all(&mut *tx)
.await?;
} else {
updated_schedules = sqlx::query_scalar!(
"UPDATE schedule SET on_recovery = NULL, on_recovery_extra_args = NULL, on_recovery_times = NULL WHERE workspace_id = $1 RETURNING path",
w_id,
)
- .fetch_all(&db)
+ .fetch_all(&mut *tx)
.await?;
}
}
@@ -1425,18 +1521,70 @@ async fn set_default_error_handler(
payload.extra_args,
w_id,
)
- .fetch_all(&db)
+ .fetch_all(&mut *tx)
.await?;
} else {
updated_schedules = sqlx::query_scalar!(
"UPDATE schedule SET on_success = NULL, on_success_extra_args = NULL WHERE workspace_id = $1 RETURNING path",
w_id,
)
- .fetch_all(&db)
+ .fetch_all(&mut *tx)
.await?;
}
}
}
+ // One row per schedule the workspace-wide override rewrote, so a handler
+ // that appeared on a schedule nobody edited is traceable. Every column
+ // the UPDATE above wrote, not just the handler path: the mute flag and
+ // the occurrence thresholds are what someone auditing a surprise
+ // notification change most needs. No `old` side and no
+ // already-had-this-value filter — the UPDATE rewrites the whole
+ // workspace unconditionally, so these rows record the write rather than
+ // a delta.
+ // Built from the same values the branch that ran actually bound: a reset
+ // (`payload.path` absent) hardcodes NULL / false in SQL while the request
+ // still carries the form's other fields, so reading them here would name
+ // values the write never produced.
+ let cleared = payload.path.is_none();
+ let handler_path = payload.path.clone();
+ let extra_args = (!cleared).then(|| payload.extra_args.clone()).flatten();
+ let times = (!cleared).then_some(payload.number_of_occurence).flatten();
+ let handler_fields = match payload.handler_type {
+ HandlerType::Error => serde_json::json!({
+ "on_failure": { "new": handler_path },
+ "on_failure_extra_args": { "new": extra_args },
+ "on_failure_times": { "new": times },
+ "on_failure_exact": {
+ "new": (!cleared).then_some(payload.number_of_occurence_exact).flatten()
+ },
+ "ws_error_handler_muted": {
+ "new": !cleared && payload.workspace_handler_muted.unwrap_or(false)
+ },
+ }),
+ HandlerType::Recovery => serde_json::json!({
+ "on_recovery": { "new": handler_path },
+ "on_recovery_extra_args": { "new": extra_args },
+ "on_recovery_times": { "new": times },
+ }),
+ HandlerType::Success => serde_json::json!({
+ "on_success": { "new": handler_path },
+ "on_success_extra_args": { "new": extra_args },
+ }),
+ };
+ trigger_history::record_bulk(
+ &mut tx,
+ &w_id,
+ SCHEDULE_TRIGGER_KIND,
+ &updated_schedules,
+ TriggerOperation::Update,
+ TriggerSource::of_request(authed.is_session_token),
+ Some(&authed.username),
+ Some(handler_fields),
+ )
+ .await?;
+
+ tx.commit().await?;
+
for updated_schedule_path in updated_schedules {
// managed ducklake-maintenance rows get the handler update (their
// failures should reach workspace handlers) but must not be
diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs
index 3f23d0416f..3b66f7317b 100644
--- a/backend/windmill-api-scripts/src/scripts.rs
+++ b/backend/windmill-api-scripts/src/scripts.rs
@@ -289,7 +289,9 @@ async fn list_scripts(
sqlb.and_where_eq("parent_hashes[array_upper(parent_hashes, 1)]", &ph.0);
}
if let Some(ph) = &lq.parent_hash {
- sqlb.and_where_eq("any(parent_hashes)", &ph.0);
+ // ANY() only ever sits on the right of the comparison; `and_where_eq` would
+ // emit it on the left and Postgres rejects that as a syntax error.
+ sqlb.and_where("? = ANY(parent_hashes)".bind(&ph.0));
}
if let Some(it) = &lq.is_template {
sqlb.and_where_eq("is_template", it);
diff --git a/backend/windmill-api-users/Cargo.toml b/backend/windmill-api-users/Cargo.toml
index c322d6cfda..13ab8143d8 100644
--- a/backend/windmill-api-users/Cargo.toml
+++ b/backend/windmill-api-users/Cargo.toml
@@ -21,7 +21,6 @@ windmill-api-auth.workspace = true
windmill-audit.workspace = true
windmill-git-sync.workspace = true
-dashmap.workspace = true
argon2.workspace = true
axum.workspace = true
chrono.workspace = true
diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs
index 96b89e322c..67e1721109 100644
--- a/backend/windmill-api-users/src/users.rs
+++ b/backend/windmill-api-users/src/users.rs
@@ -46,6 +46,7 @@ use windmill_common::audit::AuditAuthor;
use windmill_common::auth::{safe_token_prefix, TOKEN_PREFIX_LEN};
use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING;
use windmill_common::oauth2::InstanceEvent;
+use windmill_common::per_minute_counter::PerMinuteCounter;
use windmill_common::users::truncate_token;
use windmill_common::users::COOKIE_NAME;
use windmill_common::users::{
@@ -67,41 +68,24 @@ use windmill_git_sync::handle_deployment_metadata;
pub const COOKIE_PATH: &str = "/";
-const TOKEN_CREATE_LIMIT_PER_MINUTE: i32 = 10;
+const TOKEN_CREATE_LIMIT_PER_MINUTE: u32 = 10;
-struct TokenRateLimitEntry {
- count: i32,
- minute_bucket: i64,
-}
-
-static TOKEN_CREATE_RATE_LIMIT: LazyLock> =
- LazyLock::new(dashmap::DashMap::new);
+static TOKEN_CREATE_RATE_LIMIT: LazyLock> =
+ LazyLock::new(PerMinuteCounter::new);
fn check_token_create_rate_limit(username: &str) -> Result<()> {
if !*CLOUD_HOSTED {
return Ok(());
}
- let current_minute = chrono::Utc::now().timestamp() / 60;
-
- let mut entry = TOKEN_CREATE_RATE_LIMIT
- .entry(username.to_string())
- .or_insert(TokenRateLimitEntry { count: 0, minute_bucket: current_minute });
-
- if entry.minute_bucket != current_minute {
- entry.count = 0;
- entry.minute_bucket = current_minute;
+ if TOKEN_CREATE_RATE_LIMIT.try_increment(username.to_string(), TOKEN_CREATE_LIMIT_PER_MINUTE) {
+ return Ok(());
}
- if entry.count >= TOKEN_CREATE_LIMIT_PER_MINUTE {
- return Err(Error::Generic(
- StatusCode::TOO_MANY_REQUESTS,
- "Too many token creation requests. Please try again later.".to_string(),
- ));
- }
-
- entry.count += 1;
- Ok(())
+ Err(Error::Generic(
+ StatusCode::TOO_MANY_REQUESTS,
+ "Too many token creation requests. Please try again later.".to_string(),
+ ))
}
pub fn workspaced_service() -> Router {
@@ -1386,7 +1370,7 @@ async fn convert_user_to_group(
));
}
- // Determine the group with highest precedence (same logic as process_instance_group_auto_adds)
+ // Determine the group with highest precedence (same logic as reconcile_workspace_instance_groups)
let roles: std::collections::HashMap =
if let Some(roles_json) = &eligible_groups[0].instance_groups_roles {
serde_json::from_value(roles_json.clone()).unwrap_or_default()
diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs
index eff742006e..9bc4890fe6 100644
--- a/backend/windmill-api-workspaces/src/workspaces.rs
+++ b/backend/windmill-api-workspaces/src/workspaces.rs
@@ -11489,41 +11489,6 @@ struct LogFeatureUsagePayload {
events: Vec,
}
-// Only registered (feature, kind) actions are accepted, so telemetry stays
-// limited to predefined feature actions. Keys are shape-checked (identifier-like,
-// no spaces) rather than pinned to value sets: they come from our own frontend
-// (modes, tab/draft kinds, tool names, provider:model) and pinning every value
-// server-side was not worth the maintenance.
-const FEATURE_USAGE_KINDS: &[(&str, &str)] = &[
- ("ai_session", "created"),
- ("ai_session", "message"),
- ("ai_session", "autonomy"),
- ("ai_session", "tab"),
- ("ai_session", "tokens"),
- ("ai_session", "deployed"),
- ("ai_session", "archived"),
- ("ai_session", "deleted"),
- ("ai_session", "beta_optout"),
- ("ai_session", "beta_optin"),
- ("ai_chat", "message"),
- ("ai_chat", "model"),
- ("ai_chat", "tool"),
- ("flow_editor", "panel_placement"),
-];
-
-fn is_identifier_shaped(s: &str, max_len: usize) -> bool {
- !s.is_empty()
- && s.len() <= max_len
- && s.chars()
- .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.' | '/'))
-}
-
-fn valid_feature_usage_event(e: &FeatureUsageEvent) -> bool {
- FEATURE_USAGE_KINDS.contains(&(e.feature.as_str(), e.kind.as_str()))
- && (e.key.is_empty() || is_identifier_shaped(&e.key, 100))
- && (e.entity_id.is_empty() || is_identifier_shaped(&e.entity_id, 50))
-}
-
async fn log_feature_usage(
Extension(db): Extension,
Json(payload): Json,
@@ -11532,7 +11497,15 @@ async fn log_feature_usage(
// single INSERT error out ("cannot affect row a second time").
let mut agg: HashMap<(String, String, String, String), i64> = HashMap::new();
for e in payload.events.into_iter().take(MAX_FEATURE_USAGE_EVENTS) {
- if !valid_feature_usage_event(&e) {
+ // Which actions may be recorded lives in
+ // `windmill_common::feature_usage`, shared with the in-process writer so
+ // both admit exactly the same events.
+ if !windmill_common::feature_usage::is_recordable_event(
+ &e.feature,
+ &e.kind,
+ &e.key,
+ &e.entity_id,
+ ) {
continue;
}
let value = e.value.unwrap_or(1).clamp(1, 1_000_000);
@@ -11542,12 +11515,18 @@ async fn log_feature_usage(
if agg.is_empty() {
return Ok(StatusCode::NO_CONTENT);
}
- let mut features = Vec::with_capacity(agg.len());
- let mut kinds = Vec::with_capacity(agg.len());
- let mut keys = Vec::with_capacity(agg.len());
- let mut entity_ids = Vec::with_capacity(agg.len());
- let mut values = Vec::with_capacity(agg.len());
- for ((feature, kind, key, entity_id), value) in agg {
+ // Sorted for the same reason as `flush_feature_usage`: this endpoint and the
+ // backend flusher upsert the same rows, and two batches touching them in
+ // opposite orders deadlock.
+ let mut rows: Vec<((String, String, String, String), i64)> = agg.into_iter().collect();
+ rows.sort_unstable_by(|a, b| a.0.cmp(&b.0));
+
+ let mut features = Vec::with_capacity(rows.len());
+ let mut kinds = Vec::with_capacity(rows.len());
+ let mut keys = Vec::with_capacity(rows.len());
+ let mut entity_ids = Vec::with_capacity(rows.len());
+ let mut values = Vec::with_capacity(rows.len());
+ for ((feature, kind, key, entity_id), value) in rows {
features.push(feature);
kinds.push(kind);
keys.push(key);
diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml
index c61c092c95..ec641ef4ff 100644
--- a/backend/windmill-api/openapi.yaml
+++ b/backend/windmill-api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
- version: 1.789.0
+ version: 1.792.2
title: Windmill API
contact:
@@ -268,6 +268,15 @@ paths:
type: string
- $ref: "#/components/parameters/ResourceName"
- $ref: "#/components/parameters/ActionKind"
+ - name: before_id
+ in: query
+ description: >
+ only return logs with an id strictly lower than this one. Logs are ordered by
+ descending id, so this is a keyset cursor to stream a page in several batches
+ without paying a growing offset.
+ schema:
+ type: integer
+ format: int64
- name: all_workspaces
in: query
description: get audit logs for all workspaces
@@ -8035,10 +8044,19 @@ paths:
properties:
path:
type: string
- value: {}
+ value:
+ type: string
+ description: >-
+ pretty-printed JSON rendering of the resource value, capped at
+ 4000 characters — a search preview, not the value itself (use
+ get_value for that)
+ truncated:
+ type: boolean
+ description: whether value was cut short by that cap
required:
- path
- value
+ - truncated
/w/{workspace}/resources/mcp_tools/{path}:
get:
@@ -8063,11 +8081,72 @@ paths:
type: string
description:
type: string
- parameters:
+ inputSchema:
type: object
+ annotations:
+ type: object
+ properties:
+ title:
+ type: string
+ readOnlyHint:
+ type: boolean
+ destructiveHint:
+ type: boolean
+ idempotentHint:
+ type: boolean
+ openWorldHint:
+ type: boolean
required:
- name
- - parameters
+ - inputSchema
+
+ /w/{workspace}/resources/mcp_call_tool/{path}:
+ post:
+ summary: call a tool on the MCP server described by the resource
+ operationId: callMcpTool
+ tags:
+ - resource
+ parameters:
+ - $ref: "#/components/parameters/WorkspaceId"
+ - $ref: "#/components/parameters/Path"
+ requestBody:
+ description: tool name and arguments
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ tool:
+ type: string
+ arguments:
+ type: object
+ read_only:
+ type: boolean
+ description: |
+ set when the caller ran the tool without asking the user to
+ confirm it; the call is refused unless the server's live
+ listing marks the tool read-only
+ required:
+ - tool
+ responses:
+ "200":
+ description: |
+ the MCP tool result, forwarded verbatim. A tool that ran but failed
+ returns 200 with isError true.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ content:
+ type: array
+ items:
+ type: object
+ structuredContent:
+ type: object
+ isError:
+ type: boolean
/w/{workspace}/resources/list_names/{name}:
get:
@@ -20333,6 +20412,36 @@ paths:
type: string
nullable: true
+ /w/{workspace}/triggers_history/list:
+ get:
+ summary: list the history of schedule and trigger modifications
+ operationId: listTriggerHistory
+ tags:
+ - trigger
+ parameters:
+ - $ref: "#/components/parameters/WorkspaceId"
+ - $ref: "#/components/parameters/Page"
+ - $ref: "#/components/parameters/PerPage"
+ - name: trigger_kind
+ description: "'schedule' or a trigger type (http, kafka, ...)"
+ in: query
+ schema:
+ type: string
+ - name: path
+ description: only return the history of the trigger at this path
+ in: query
+ schema:
+ type: string
+ responses:
+ "200":
+ description: trigger history
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/TriggerHistoryEntry"
+
/w/{workspace}/folders/list:
get:
summary: list folders
@@ -23738,6 +23847,55 @@ paths:
items:
$ref: "#/components/schemas/AssetProgress"
+ /w/{workspace}/jobs/run_assets/{id}:
+ get:
+ summary: List the assets a run touched at runtime
+ description: >
+ Assets detected while the run executed (SDK S3 calls, resources passed as
+ arguments), aggregated over the job and all of its child jobs so a flow or
+ workflow-as-code run reports what its steps and tasks touched. Authorized
+ through the job, the same gate as `run_progress`. Recording is asynchronous,
+ so an asset can take a few minutes after the run to appear, and only the most
+ recent runs that touched an asset keep that record. A run that fans out can
+ touch more assets than one response should carry, so the list is capped and
+ `truncated` says when it was cut.
+ operationId: listRunAssets
+ tags:
+ - job
+ parameters:
+ - $ref: "#/components/parameters/WorkspaceId"
+ - name: id
+ in: path
+ required: true
+ description: The job whose runtime assets to read
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: assets this run and its child jobs touched
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [assets, truncated]
+ properties:
+ truncated:
+ type: boolean
+ description: whether the run touched more assets than are listed
+ assets:
+ type: array
+ items:
+ type: object
+ required: [path, kind]
+ properties:
+ path:
+ type: string
+ kind:
+ $ref: "#/components/schemas/AssetKind"
+ access_type:
+ $ref: "#/components/schemas/AssetUsageAccessType"
+
/w/{workspace}/assets/partitions_in_range:
get:
summary: List expected partitions of a ducklake asset in a date range with their materialization status (enterprise)
@@ -26263,6 +26421,8 @@ components:
type: integer
cache_ttl:
type: number
+ cache_ignore_s3_path:
+ type: boolean
dedicated_worker:
type: boolean
ws_error_handler_muted:
@@ -28236,6 +28396,44 @@ components:
is_fileset:
type: boolean
+ TriggerHistoryEntry:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ trigger_kind:
+ type: string
+ description: "'schedule' or a trigger type (http, kafka, ...)"
+ path:
+ type: string
+ operation:
+ type: string
+ enum: [create, update, delete, enable, disable, suspend]
+ source:
+ type: string
+ description: The kind of client the change came from. `worker` means the server disabled the trigger on its own after a failure.
+ enum: [ui, cli, api, worker]
+ username:
+ type: string
+ nullable: true
+ description: Unset when the server acted on its own.
+ created_at:
+ type: string
+ format: date-time
+ changes:
+ type: object
+ nullable: true
+ additionalProperties: true
+ description: "{field: {old, new}} for the fields that actually changed. Unset for a delete."
+ required:
+ - id
+ - trigger_kind
+ - path
+ - operation
+ - source
+ - created_at
+
Schedule:
type: object
properties:
@@ -31779,6 +31977,13 @@ components:
execution_mode:
type: string
enum: [viewer, publisher, anonymous]
+ description: >-
+ Who the app's runnables execute as. Optional, and what omitting it
+ means depends on the operation: creating an app defaults it to
+ `publisher` (runs on behalf of the app's publisher and requires an
+ authenticated viewer), while updating one keeps the mode the app is
+ already deployed under. Either way `anonymous`, which makes the app
+ publicly executable, is never assumed
on_behalf_of:
type: string
on_behalf_of_email:
diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs
index 037f701346..3b0fc60d79 100644
--- a/backend/windmill-api/src/apps.rs
+++ b/backend/windmill-api/src/apps.rs
@@ -287,8 +287,11 @@ pub type AllowUserResources = Vec;
#[derive(Serialize, Deserialize, Debug, PartialEq, Copy, Clone, Default)]
#[serde(rename_all = "lowercase")]
pub enum ExecutionMode {
- #[default]
Anonymous,
+ /// Default for a policy that omits `execution_mode`. It MUST stay a mode
+ /// that requires an authenticated viewer: an omitted field must never be
+ /// able to publish an app anonymously (publicly executable).
+ #[default]
Publisher,
Viewer,
}
@@ -334,7 +337,13 @@ pub struct Policy {
pub triggerables: Option>,
#[serde(skip_serializing_if = "Option::is_none")]
pub triggerables_v2: Option>,
- pub execution_mode: ExecutionMode,
+ /// `None` when the policy states no mode, which the OpenAPI schema (and so
+ /// the MCP tools generated from it) allows. Create resolves that to
+ /// [`ExecutionMode::default`]; update keeps the deployed app's mode, so a
+ /// partial policy cannot silently re-permission an app. Read the effective
+ /// mode with [`Policy::execution_mode`], never this field.
+ #[serde(default, rename = "execution_mode")]
+ stated_execution_mode: Option,
pub s3_inputs: Option>,
pub allowed_s3_keys: Option>,
// WIN-2006: publisher opt-in to iframe sandbox isolation (alpha). When true the
@@ -351,6 +360,25 @@ pub struct Policy {
pub frontend_sdk_scopes: Option>,
}
+impl Policy {
+ /// The mode this policy runs under. A policy that states none resolves to
+ /// [`ExecutionMode::default`], never to `anonymous`, so an app can only be
+ /// publicly executable because someone said so.
+ pub fn execution_mode(&self) -> ExecutionMode {
+ self.stated_execution_mode.unwrap_or_default()
+ }
+
+ /// What the policy states, or `None` for "not stated". Only the write paths
+ /// need this: everything else wants [`Policy::execution_mode`].
+ fn stated_execution_mode(&self) -> Option {
+ self.stated_execution_mode
+ }
+
+ pub fn set_execution_mode(&mut self, execution_mode: ExecutionMode) {
+ self.stated_execution_mode = Some(execution_mode);
+ }
+}
+
#[derive(Deserialize)]
pub struct CreateApp {
pub path: String,
@@ -1189,7 +1217,7 @@ async fn get_public_app_by_secret(
let policy = serde_json::from_str::(app.policy.0.get()).map_err(to_anyhow)?;
- if !matches!(policy.execution_mode, ExecutionMode::Anonymous) {
+ if !matches!(policy.execution_mode(), ExecutionMode::Anonymous) {
if opt_authed.is_none() {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are not logged in".to_string(),
@@ -2199,7 +2227,10 @@ async fn create_app_internal<'a>(
));
}
}
- if matches!(app.policy.execution_mode, ExecutionMode::Anonymous) {
+ // Pin the mode the app is created under, so the stored policy states one
+ // even when the caller did not.
+ app.policy.set_execution_mode(app.policy.execution_mode());
+ if matches!(app.policy.execution_mode(), ExecutionMode::Anonymous) {
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
w_id,
&ProtectionRuleKind::RestrictAnonymousAppDeployment,
@@ -2612,7 +2643,7 @@ async fn update_app(
let opath = path.to_string();
let db2 = db.clone();
let (new_tx, npath, v_id) =
- update_app_internal(authed, db, user_db, &w_id, path, false, ns).await?;
+ update_app_internal(authed, db, user_db, &w_id, path, false, ns, None).await?;
new_tx.commit().await?;
tally_app_rename(&db2, &w_id, &opath, &npath, v_id).await;
@@ -2671,7 +2702,7 @@ async fn update_app_raw_source(
"value with the app's `files` is required to deploy a raw app".to_string(),
));
};
- let files: RawAppSourceFiles = serde_json::from_str(value.0.get())
+ let value: RawAppSourceValue = serde_json::from_str(value.0.get())
.map_err(|e| Error::BadRequest(format!("app value is not a raw app source: {e}")))?;
// All before the compile, which costs a job on a worker: it must not run for
@@ -2690,17 +2721,41 @@ async fn update_app_raw_source(
reject_kind_change(path, true, Some(deployed_raw_app))?;
}
- let (js, css) =
- apps_raw_bundle::bundle_raw_app_sources(&db, &user_db, &authed, &w_id, &files.files)
- .await?;
+ let bundled = apps_raw_bundle::bundle_raw_app_sources(
+ &db,
+ &user_db,
+ &authed,
+ &w_id,
+ &value.files,
+ &value.runnables,
+ )
+ .await?;
let opath = path.to_string();
let db2 = db.clone();
- let (mut tx, npath, v_id) =
- update_app_internal(authed, db, user_db, &w_id, path, true, ns).await?;
- store_raw_app_file(&w_id, &v_id, "js", bytes::Bytes::from(js), &mut tx).await?;
- if !css.is_empty() {
- store_raw_app_file(&w_id, &v_id, "css", bytes::Bytes::from(css), &mut tx).await?;
+ // The new sources bring new runnables, so the deployed triggerables no longer
+ // describe them. Merged inside, under the app-row lock.
+ let (mut tx, npath, v_id) = update_app_internal(
+ authed,
+ db,
+ user_db,
+ &w_id,
+ path,
+ true,
+ ns,
+ Some(bundled.triggerables_v2),
+ )
+ .await?;
+ store_raw_app_file(&w_id, &v_id, "js", bytes::Bytes::from(bundled.js), &mut tx).await?;
+ if !bundled.css.is_empty() {
+ store_raw_app_file(
+ &w_id,
+ &v_id,
+ "css",
+ bytes::Bytes::from(bundled.css),
+ &mut tx,
+ )
+ .await?;
}
tx.commit().await?;
tally_app_rename(&db2, &w_id, &opath, &npath, v_id).await;
@@ -2775,7 +2830,7 @@ async fn create_app_raw_source(
Extension(db): Extension,
Extension(webhook): Extension,
Path(w_id): Path,
- Json(app): Json,
+ Json(mut app): Json,
) -> Result<(StatusCode, String)> {
if authed.is_operator {
return Err(Error::NotAuthorized(
@@ -2801,7 +2856,7 @@ async fn create_app_raw_source(
return Err(Error::PermissionDenied(msg));
}
- let files: RawAppSourceFiles = serde_json::from_str(app.value.0.get())
+ let value: RawAppSourceValue = serde_json::from_str(app.value.0.get())
.map_err(|e| Error::BadRequest(format!("app value is not a raw app source: {e}")))?;
// Before the compile, which costs a job on a worker: it must not run for a
@@ -2816,14 +2871,31 @@ async fn create_app_raw_source(
)));
}
- let (js, css) =
- apps_raw_bundle::bundle_raw_app_sources(&db, &user_db, &authed, &w_id, &files.files)
- .await?;
+ let bundled = apps_raw_bundle::bundle_raw_app_sources(
+ &db,
+ &user_db,
+ &authed,
+ &w_id,
+ &value.files,
+ &value.runnables,
+ )
+ .await?;
+ // The bundle carries the grants for the runnables it was built from, so the
+ // app cannot be deployed with a policy that does not describe them.
+ app.policy.triggerables = None;
+ app.policy.triggerables_v2 = Some(bundled.triggerables_v2);
let (mut tx, npath, v_id) = create_app_internal(authed, db, user_db, &w_id, true, app).await?;
- store_raw_app_file(&w_id, &v_id, "js", bytes::Bytes::from(js), &mut tx).await?;
- if !css.is_empty() {
- store_raw_app_file(&w_id, &v_id, "css", bytes::Bytes::from(css), &mut tx).await?;
+ store_raw_app_file(&w_id, &v_id, "js", bytes::Bytes::from(bundled.js), &mut tx).await?;
+ if !bundled.css.is_empty() {
+ store_raw_app_file(
+ &w_id,
+ &v_id,
+ "css",
+ bytes::Bytes::from(bundled.css),
+ &mut tx,
+ )
+ .await?;
}
tx.commit().await?;
@@ -2835,11 +2907,19 @@ async fn create_app_raw_source(
Ok((StatusCode::CREATED, npath))
}
-/// The part of a raw app's value the bundler needs.
+/// The part of a raw app's value the source endpoints read: `files` to bundle,
+/// and `runnables` to derive the policy from, passed through untouched because
+/// the CLI is what reads them.
#[derive(Deserialize)]
-struct RawAppSourceFiles {
+struct RawAppSourceValue {
#[serde(default)]
files: HashMap,
+ #[serde(default = "empty_runnables")]
+ runnables: Box,
+}
+
+fn empty_runnables() -> Box {
+ RawValue::from_string("{}".to_string()).expect("valid json")
}
fn reject_kind_change(path: &str, raw_app: bool, deployed_raw_app: Option) -> Result<()> {
@@ -2945,7 +3025,11 @@ async fn update_app_raw<'a>(
&w_id,
path,
multipart,
- update_app_internal
+ // `/apps/update_raw` carries a whole prebuilt app: its policy comes from
+ // the caller (the editor, the CLI), which derived the triggerables itself.
+ |authed, db, user_db, w_id, path, raw_app, app| update_app_internal(
+ authed, db, user_db, w_id, path, raw_app, app, None
+ )
)
.await?;
tally_app_rename(&db2, &w_id, &opath, &npath, v_id).await;
@@ -2976,7 +3060,11 @@ async fn update_app_internal<'a>(
w_id: &str,
path: &str,
raw_app: bool,
- ns: EditApp,
+ mut ns: EditApp,
+ // Raw-app source deploys derive these from the value on a worker. Merged
+ // into the policy here rather than by the caller so it happens under the
+ // app-row lock taken below.
+ derived_triggerables: Option>,
) -> Result<(sqlx::Transaction<'a, sqlx::Postgres>, String, i64)> {
use sql_builder::prelude::*;
@@ -3021,6 +3109,10 @@ async fn update_app_internal<'a>(
|| ns.summary.is_some()
|| ns.custom_path.is_some()
|| ns.labels.is_some()
+ // A source deploy may send nothing but the value, and its runnables are
+ // what the derived grants describe: skipping the UPDATE here would keep
+ // the grants keyed to the sources this deploy just replaced.
+ || derived_triggerables.is_some()
{
let mut sqlb = SqlBuilder::update_table("app");
sqlb.and_where_eq("path", "?".bind(&path));
@@ -3088,24 +3180,66 @@ async fn update_app_internal<'a>(
}
}
- if let Some(mut npolicy) = ns.policy {
+ // A source deploy brings triggerables derived from its value but may send
+ // no policy at all, in which case the rest of the deployed one carries
+ // over. Either way a policy is about to be written wholesale.
+ let caller_sent_policy = ns.policy.is_some();
+ if caller_sent_policy || derived_triggerables.is_some() {
+ // One locked read serving everything below: the mode an omitted one
+ // inherits, the already-anonymous check, and the policy a source
+ // deploy carries over. FOR UPDATE holds the row until this
+ // transaction's UPDATE commits, so a policy edit landing between the
+ // read and the write cannot be clobbered by this stale snapshot.
+ let deployed = sqlx::query_scalar!(
+ "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE",
+ path,
+ w_id
+ )
+ .fetch_optional(&mut *tx)
+ .await?;
+ let deployed_policy = deployed
+ .as_ref()
+ .and_then(|p| serde_json::from_value::(p.clone()).ok());
+
+ let mut npolicy = match ns.policy.take() {
+ Some(npolicy) => npolicy,
+ // Carrying the deployed policy forward is only safe if all of it
+ // survived the round trip; a partial one would silently drop
+ // `on_behalf_of`, sandboxing or S3 rules.
+ None => deployed_policy.clone().ok_or_else(|| {
+ Error::internal_err(format!(
+ "app {path} has no readable policy to deploy its new sources under"
+ ))
+ })?,
+ };
+ if let Some(triggerables) = derived_triggerables {
+ npolicy.triggerables = None;
+ npolicy.triggerables_v2 = Some(triggerables);
+ }
validate_frontend_sdk_scopes(&npolicy)?;
- if matches!(npolicy.execution_mode, ExecutionMode::Anonymous) && !authed.is_admin {
+ // The policy is written wholesale, so one that states no mode would
+ // otherwise re-permission the app to the create-time default: a
+ // `viewer` app would start running on the publisher's identity. Keep
+ // the deployed mode instead, and make the stored policy state it.
+ if npolicy.stated_execution_mode().is_none() {
+ npolicy.set_execution_mode(
+ deployed_policy
+ .as_ref()
+ .map(|d| d.execution_mode())
+ .unwrap_or_default(),
+ );
+ }
+ if matches!(npolicy.execution_mode(), ExecutionMode::Anonymous) && !authed.is_admin {
// Restricted users may keep deploying an app that is already
// public, but flipping an app to anonymous (public) access is
// gated by the RestrictAnonymousAppDeployment protection rule.
- // FOR UPDATE locks the row until this transaction's policy
- // UPDATE commits, so a concurrent admin downgrade cannot be
- // silently overwritten by a stale redeploy keeping anonymous.
- let already_anonymous = sqlx::query_scalar!(
- "SELECT policy->>'execution_mode' = 'anonymous' FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE",
- path,
- w_id
- )
- .fetch_optional(&mut *tx)
- .await?
- .flatten()
- .unwrap_or(false);
+ // An unreadable deployed policy reads as not-anonymous, the
+ // strict direction.
+ let already_anonymous = deployed
+ .as_ref()
+ .and_then(|p| p.get("execution_mode"))
+ .and_then(|m| m.as_str())
+ == Some("anonymous");
if !already_anonymous {
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
w_id,
@@ -3131,7 +3265,10 @@ async fn update_app_internal<'a>(
preserved_on_behalf_of = Some(obo_email.clone());
}
}
- } else {
+ } else if caller_sent_policy {
+ // Submitting a policy is how a deployer claims the app's
+ // execution identity. A source deploy that sent none is not
+ // claiming anything, so whoever the app already runs as stays.
npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
npolicy.on_behalf_of_email = Some(authed.email.clone());
}
@@ -3345,7 +3482,7 @@ async fn get_on_behalf_details_from_policy_and_authed(
policy: &Policy,
opt_authed: &Option,
) -> Result<(String, String, String)> {
- let (username, permissioned_as, email) = match policy.execution_mode {
+ let (username, permissioned_as, email) = match policy.execution_mode() {
ExecutionMode::Anonymous => {
let username = opt_authed
.as_ref()
@@ -3542,7 +3679,7 @@ async fn execute_component(
force_viewer_delete_after_secs,
..
} => (
- &Policy { execution_mode: ExecutionMode::Viewer, ..Default::default() },
+ &Policy { stated_execution_mode: Some(ExecutionMode::Viewer), ..Default::default() },
&PolicyTriggerableInputs {
static_inputs,
one_of_inputs: force_viewer_one_of_fields.unwrap_or_default(),
@@ -3639,7 +3776,7 @@ async fn execute_component(
let policy_triggerables = triggerables_v2
.get(path) // start with `path` in case we can avoid the next` format!`.
.or_else(|| triggerables_v2.get(&format!("{}:{}", payload.component, &path)))
- .or(match policy.execution_mode {
+ .or(match policy.execution_mode() {
// A Viewer app may invoke any deployed `script`/`flow` it
// references (resolved as the caller), but caller-supplied
// inline `raw_code` must match a publisher-pinned
@@ -3658,7 +3795,7 @@ async fn execute_component(
};
// Check rate limit for anonymous (public) executions
- if matches!(policy.execution_mode, ExecutionMode::Anonymous) && opt_authed.is_none() {
+ if matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none() {
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
if limit > 0 {
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
@@ -3668,7 +3805,8 @@ async fn execute_component(
// Execution is publisher and an user is authenticated: check if the user is authorized to
// execute the app.
- if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode, opt_authed.as_ref()) {
+ if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode(), opt_authed.as_ref())
+ {
lazy_static! {
/// Cache for the permit to execute an app component.
static ref PERMIT_CACHE: cache::Cache<[u8; 32], bool> = cache::Cache::new(1000);
@@ -3993,7 +4131,7 @@ async fn upload_s3_file_from_app(
}
check_scopes(authed, || format!("apps:write:{}", path.to_path()))?;
Some(Policy {
- execution_mode: ExecutionMode::Viewer,
+ stated_execution_mode: Some(ExecutionMode::Viewer),
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
@@ -4406,7 +4544,7 @@ async fn get_on_behalf_authed_from_app(
) -> Result<(ApiAuthed, Policy)> {
let policy = if let Some(force_allowed_s3_keys) = force_allowed_s3_keys {
Policy {
- execution_mode: ExecutionMode::Viewer,
+ stated_execution_mode: Some(ExecutionMode::Viewer),
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
@@ -4430,7 +4568,7 @@ async fn get_on_behalf_authed_from_app(
.map(|p| serde_json::from_value::(p).map_err(to_anyhow))
.transpose()?
.unwrap_or_else(|| Policy {
- execution_mode: ExecutionMode::Viewer,
+ stated_execution_mode: Some(ExecutionMode::Viewer),
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
@@ -4497,7 +4635,7 @@ async fn check_if_allowed_to_access_s3_file_from_app(
}
}
- if matches!(policy.execution_mode, ExecutionMode::Viewer) && !is_app_embed {
+ if matches!(policy.execution_mode(), ExecutionMode::Viewer) && !is_app_embed {
// Viewer mode: the on-behalf identity IS the viewer, so the downstream
// get_workspace_s3_resource_and_check_paths already bounds the read by
// their own perms — no provenance gate (it would over-restrict). Embed
@@ -5507,3 +5645,25 @@ mod embed_token_tests {
assert!(parse_embed_policy("not json").is_err());
}
}
+
+#[cfg(test)]
+mod policy_tests {
+ /// `execution_mode` is optional in the OpenAPI schema the API clients and the
+ /// MCP tools are generated from, so an omitted one must deserialize. It must
+ /// resolve to `publisher`, never `anonymous`, and must stay distinguishable
+ /// from a stated `publisher` so the update path can keep the deployed mode
+ /// instead of re-permissioning the app.
+ #[test]
+ fn policy_execution_mode_defaults_to_publisher() {
+ use super::{ExecutionMode, Policy};
+
+ let p: Policy = serde_json::from_str("{}").expect("empty policy must deserialize");
+ assert_eq!(p.execution_mode(), ExecutionMode::Publisher);
+ assert_eq!(p.stated_execution_mode(), None);
+
+ // An explicit mode still wins, and reads back as stated.
+ let p: Policy = serde_json::from_str(r#"{"execution_mode": "anonymous"}"#).unwrap();
+ assert_eq!(p.execution_mode(), ExecutionMode::Anonymous);
+ assert_eq!(p.stated_execution_mode(), Some(ExecutionMode::Anonymous));
+ }
+}
diff --git a/backend/windmill-api/src/apps_raw_bundle.rs b/backend/windmill-api/src/apps_raw_bundle.rs
index 833e7c6b58..f50f94f8eb 100644
--- a/backend/windmill-api/src/apps_raw_bundle.rs
+++ b/backend/windmill-api/src/apps_raw_bundle.rs
@@ -28,11 +28,24 @@ use windmill_common::{
};
use windmill_queue::{push, PushArgs, PushIsolationLevel};
+use crate::apps::PolicyTriggerableInputs;
use crate::db::ApiAuthed;
/// The bundle job's script, as its own file so it stays readable TypeScript.
const BUNDLER_TS: &str = include_str!("apps_raw_bundler.ts");
+/// The frontend's policy derivation, bundled by `cli/generate-app-policy.ts` and
+/// prepended to the job so it runs there. Carried in the binary rather than
+/// invoked through the job's `wmill`: the images install the CLI unpinned, so an
+/// image can hold one older than its server, and reaching for a pinned release
+/// off npm is what the installed-CLI branch below exists to avoid.
+const POLICY_JS: &str = include_str!("apps_raw_policy.gen.js");
+
+/// What the job runs: the derivation, then the bundler that calls it.
+fn bundle_job_script() -> String {
+ format!("{POLICY_JS}\n{BUNDLER_TS}")
+}
+
/// Cap the job so a pathological `package.json` can't sit on a worker forever.
/// This bounds the *run*, not the wait: see `wait_for_bundle`.
const BUNDLE_TIMEOUT_SECS: i32 = 300;
@@ -41,6 +54,18 @@ const BUNDLE_TIMEOUT_SECS: i32 = 300;
struct BundleResult {
js_gz: String,
css_gz: String,
+ /// Parsed rather than passed through: a policy the server cannot read is a
+ /// failed deploy, not an app whose runnables are refused at run time.
+ #[serde(default)]
+ triggerables_v2: HashMap,
+}
+
+/// What a bundle produced: the js/css a deployed raw app serves, and the
+/// `triggerables_v2` its policy grants.
+pub(crate) struct BundledApp {
+ pub js: String,
+ pub css: String,
+ pub triggerables_v2: HashMap,
}
/// This server's release, without the git describe suffix an off-tag build
@@ -55,7 +80,7 @@ fn release_version() -> String {
/// installed: the CLI for this server's release, fetched on the spot. A dev
/// server is off-tag and so asks for the last release; to build with an
/// unreleased CLI set `WM_RAW_APP_BUNDLER_CLI` to the whole command, e.g.
-/// `bun run /path/to/cli/src/main.ts app bundle` — which also stops the job from
+/// `bun run /path/to/cli/src/main.ts app bundle`, which also stops the job from
/// preferring an installed `wmill`.
fn bundler_cli_command() -> Vec {
match std::env::var("WM_RAW_APP_BUNDLER_CLI") {
@@ -88,7 +113,8 @@ pub(crate) async fn bundle_raw_app_sources(
authed: &ApiAuthed,
w_id: &str,
files: &HashMap,
-) -> Result<(String, String)> {
+ runnables: &serde_json::value::RawValue,
+) -> Result {
crate::utils::check_scopes(authed, || "jobs:run".to_string())?;
if files.is_empty() {
@@ -119,6 +145,7 @@ pub(crate) async fn bundle_raw_app_sources(
"prefer_installed_cli".to_string(),
to_raw_value(&!overridden),
);
+ args.insert("runnables".to_string(), runnables.to_owned());
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let (uuid, tx) = push(
@@ -127,7 +154,7 @@ pub(crate) async fn bundle_raw_app_sources(
w_id,
JobPayload::Code(RawCode {
hash: None,
- content: BUNDLER_TS.to_string(),
+ content: bundle_job_script(),
path: Some("bundle raw app".to_string()),
language: ScriptLang::Bun,
lock: None,
@@ -179,7 +206,7 @@ async fn wait_for_bundle(
w_id: &str,
uuid: Uuid,
authed: &ApiAuthed,
-) -> Result<(String, String)> {
+) -> Result {
let (result, success) = windmill_api_jobs::execution::run_wait_result_internal(
db,
uuid,
@@ -207,7 +234,7 @@ async fn wait_for_bundle(
let limit = *crate::REQUEST_SIZE_LIMIT.read().await * 5;
let js = gunzip_b64(&bundle.js_gz, limit)?;
let css = gunzip_b64(&bundle.css_gz, limit - js.len())?;
- Ok((js, css))
+ Ok(BundledApp { js, css, triggerables_v2: bundle.triggerables_v2 })
}
/// Bounded: what the job returns is compressed, so the result-size cap says
diff --git a/backend/windmill-api/src/apps_raw_bundler.ts b/backend/windmill-api/src/apps_raw_bundler.ts
index ceefc93207..6663a0f83e 100644
--- a/backend/windmill-api/src/apps_raw_bundler.ts
+++ b/backend/windmill-api/src/apps_raw_bundler.ts
@@ -9,13 +9,23 @@
* and the Svelte/Vue plugins included. Reimplementing any of that here would be
* a third bundler to keep in step with the other two.
*/
+declare const __wmillAppPolicy: {
+ updateRawAppPolicy: (
+ runnables: Record,
+ current: undefined
+ ) => Promise<{ triggerables_v2: Record }>
+}
+
export async function main(
files: Record,
shared_ui: Record | undefined,
cli_command: string[],
// Set unless the server was told to build with a specific command.
- prefer_installed_cli: boolean | undefined
-): Promise<{ js_gz: string; css_gz: string }> {
+ prefer_installed_cli: boolean | undefined,
+ // The app's `value.runnables`, whose policy is derived here for the same
+ // reason the bundle is built here: it has to match what the editor writes.
+ runnables: Record | undefined
+): Promise<{ js_gz: string; css_gz: string; triggerables_v2: Record }> {
const fs = await import('node:fs/promises')
const path = await import('node:path')
@@ -114,8 +124,53 @@ export async function main(
throw new Error('bundle produced no javascript:\n' + buildOutput)
}
+ // A runnable the derivation can't fully classify still yields a key, just an
+ // unusable one (`r:undefined/undefined`, or the hash of an absent script), and
+ // the deploy would then succeed with grants no run can ever match. The tool
+ // schema describes `runnables` only as an object and the on-disk format has no
+ // discriminator at all (`wmill app push` adds it), so these shapes are all
+ // reachable: check them before deriving and name the ones at fault. An
+ // explicitly empty entry is a runnable nobody configured yet, and needs no
+ // grant.
+ const nonEmpty = (v: unknown) => typeof v === 'string' && v.length > 0
+ // The prefixes `execute_component` resolves a run against; anything else is a
+ // grant no run can match.
+ const RUN_TYPES = ['script', 'flow', 'hubscript']
+ const malformed = Object.entries(runnables ?? {})
+ .filter(([, r]) => r != null)
+ .filter(([, r]) => {
+ if (typeof r !== 'object') return true
+ const run = r as Record
+ if (run.type === 'inline' || run.type === 'runnableByName') {
+ return !nonEmpty(run.inlineScript?.content)
+ }
+ if (run.type === 'path' || run.type === 'runnableByPath') {
+ return !RUN_TYPES.includes(run.runType) || !nonEmpty(run.path)
+ }
+ return true
+ })
+ .map(([id]) => id)
+ if (malformed.length > 0) {
+ throw new Error(
+ `no policy could be derived for runnable(s) ${malformed.join(', ')}: each must be an ` +
+ `object with a \`type\` of "inline" (with \`inlineScript.content\`) or "path" (with ` +
+ `\`path\` and a \`runType\` of ${RUN_TYPES.join(', ')})`
+ )
+ }
+
+ // The policy's `triggerables_v2` is the allowlist the server matches every run
+ // against, keyed by a hash of each inline runnable's code. Derived by the
+ // frontend's own code, bundled into this script by cli/generate-app-policy.ts,
+ // so the keys are the ones the app editor writes: anything else leaves the
+ // app's runnables "forbidden by policy". Prepended above as a plain `var`, so
+ // it is in this module's scope (a module's top-level `var` is not a global).
+ const { triggerables_v2 } = await __wmillAppPolicy.updateRawAppPolicy(
+ runnables ?? {},
+ undefined
+ )
+
// Gzipped so a large app's bundle stays well inside MAX_RESULT_SIZE_MB, which
// a deployment can set far below the 500MB default.
const gz = (s: string) => Buffer.from(Bun.gzipSync(Buffer.from(s, 'utf8'))).toString('base64')
- return { js_gz: gz(js), css_gz: gz(css) }
+ return { js_gz: gz(js), css_gz: gz(css), triggerables_v2 }
}
diff --git a/backend/windmill-api/src/apps_raw_policy.gen.js b/backend/windmill-api/src/apps_raw_policy.gen.js
new file mode 100644
index 0000000000..1d7c5a48fc
--- /dev/null
+++ b/backend/windmill-api/src/apps_raw_policy.gen.js
@@ -0,0 +1,7 @@
+// Generated by cli/generate-app-policy.ts. Do not edit.
+// Run `bun run gen:app-policy` from cli/ to rebuild it from
+// frontend/src/lib/components/raw_apps/rawAppPolicy.ts.
+//
+// Prepended to the raw-app bundle job (see apps_raw_bundle.rs), which calls
+// `__wmillAppPolicy.updateRawAppPolicy` from its own module scope.
+var __wmillAppPolicy=(()=>{var g=Object.create;var i=Object.defineProperty;var b=Object.getOwnPropertyDescriptor;var m=Object.getOwnPropertyNames;var A=Object.getPrototypeOf,S=Object.prototype.hasOwnProperty;var x=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,p)=>(typeof require<"u"?require:t)[p]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var f=(e,t)=>{for(var p in t)i(e,p,{get:t[p],enumerable:!0})},s=(e,t,p,a)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of m(t))!S.call(e,r)&&r!==p&&i(e,r,{get:()=>t[r],enumerable:!(a=b(t,r))||a.enumerable});return e};var h=(e,t,p)=>(p=e!=null?g(A(e)):{},s(t||!e||!e.__esModule?i(p,"default",{value:e,enumerable:!0}):p,e)),R=e=>s(i({},"__esModule",{value:!0}),e);var v={};f(v,{updateRawAppPolicy:()=>d});function u(e){return Object.fromEntries(Object.entries(e??{}).filter(([t,p])=>p.type=="static").map(([t,p])=>[t,p.value]))}async function c(e){try{let t=new TextEncoder().encode(e),p=await crypto.subtle.digest("SHA-256",t);return Array.from(new Uint8Array(p)).map(n=>n.toString(16).padStart(2,"0")).join("")}catch{let{Sha256:t}=await import("@aws-crypto/sha256-js"),p=new t;return p.update(e??""),Array.from(await p.digest()).map(n=>n.toString(16).padStart(2,"0")).join("")}}function l(e){return e?.type=="runnableByPath"||e?.type=="path"}function y(e){return e?.type=="runnableByName"||e?.type=="inline"}async function d(e,t){let p=(await Promise.all(Object.entries(e).map(async([n,o])=>await _(n,o,o?.fields??{})))).filter(n=>n!=null),a=Object.fromEntries(p);return{...t,triggerables_v2:a}}function I(e,t){let p={};typeof e.delete_after_secs=="number"&&e.delete_after_secs>=0&&(p.delete_after_secs=e.delete_after_secs);let a=Object.entries(t).map(([r,n])=>n.sensitive?r:void 0).filter(Boolean);return a.length>0&&(p.sensitive_inputs=a),e.inlineScript?.tag&&(p.tag=e.inlineScript.tag),p}async function _(e,t,p){let a=u(p),r=Object.entries(p).map(([n,o])=>o.allowUserResources?n:void 0).filter(Boolean);if(y(t)){let n=await c(t.inlineScript?.content);return[`${e}:rawscript/${n}`,{static_inputs:a,one_of_inputs:{},allow_user_resources:r,...I(t,p)}]}else if(l(t)){let n=t.runType!=="hubscript"?t.runType:"script";return[`${e}:${n}/${t.path}`,{static_inputs:a,one_of_inputs:{},allow_user_resources:r,...I(t,p)}]}}return R(v);})();
diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs
index acfdc6639b..584c44bbec 100644
--- a/backend/windmill-api/src/jobs.rs
+++ b/backend/windmill-api/src/jobs.rs
@@ -25,6 +25,7 @@ use std::time::Instant;
use tokio::io::AsyncReadExt;
use tower::ServiceBuilder;
use url::Url;
+use windmill_common::assets::AssetUsageAccessType;
#[cfg(all(feature = "enterprise", feature = "instance_smtp"))]
use windmill_common::auth::is_super_admin_email;
use windmill_common::auth::TOKEN_PREFIX_LEN;
@@ -78,7 +79,7 @@ use crate::{
users::{
get_scope_tags, require_owner_of_path, require_path_read_access_for_preview, OptAuthed,
},
- utils::{check_scopes, content_plain, require_super_admin},
+ utils::{build_scope_path_predicate, check_scopes, content_plain, require_super_admin},
};
use anyhow::Context;
use axum::{
@@ -136,6 +137,7 @@ pub fn workspaced_service() -> Router {
Router::new()
.route("/run_progress/{id}", get(get_run_progress))
+ .route("/run_assets/{id}", get(list_run_assets))
.route("/dbt_graph/{id}", get(get_dbt_run_graph))
.route("/dbt_resumable/{id}", get(get_dbt_resumable))
.route(
@@ -1188,6 +1190,121 @@ async fn get_run_progress(
Ok(Json(rows))
}
+#[derive(Serialize)]
+struct RunAsset {
+ path: String,
+ kind: windmill_common::assets::AssetKind,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ access_type: Option,
+}
+
+#[derive(Serialize)]
+struct RunAssets {
+ assets: Vec,
+ truncated: bool,
+}
+
+/// A fan-out run — a forloop writing one object per iteration — touches as many
+/// assets as it has steps, and the whole list would land in one response and one
+/// list in the browser. Cap it, and say so rather than serving a prefix that
+/// reads like the whole answer.
+const RUN_ASSETS_CAP: usize = 1000;
+
+/// Assets a run touched, as recorded by runtime detection, aggregated over the
+/// whole job tree: the recorder attributes an asset to the job that performed
+/// the operation, which for a flow step or a workflow-as-code task is not the
+/// job the user opened. Lives with the job routes for the same reason
+/// `run_progress` does — `asset` has no RLS, and `require_job_read_access` is
+/// what applies the view token, a scoped token's tag filter and the app-embed
+/// cutoff.
+async fn list_run_assets(
+ authed: ApiAuthed,
+ OptViewToken(view_token): OptViewToken,
+ Extension(db): Extension,
+ Extension(user_db): Extension,
+ Path((w_id, job_id)): Path<(String, Uuid)>,
+) -> error::JsonResult {
+ let created_by = sqlx::query_scalar!(
+ "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
+ job_id,
+ &w_id
+ )
+ .fetch_optional(&db)
+ .await?;
+ let Some(created_by) = created_by else {
+ return Ok(Json(RunAssets { assets: vec![], truncated: false }));
+ };
+ require_job_read_access(
+ &db,
+ &user_db,
+ &authed,
+ &w_id,
+ &job_id,
+ &created_by,
+ view_token.as_deref(),
+ )
+ .await?;
+
+ // Walked on `db`, like the flow tree is: the gate above is what authorizes the
+ // run, and it grants access the caller's own RLS does not have — a view token,
+ // or a job the caller launched that runs as someone else. Re-filtering the tree
+ // through `user_db` would drop exactly those, and hand a share-link viewer the
+ // empty tab this endpoint exists to fix. The tag scope is the one restriction
+ // that must still hold per job, since the gate only checked the root. It gates
+ // which jobs' assets are read, not which are walked through: the gate admits a
+ // job on its own tag, so an out-of-scope job in the middle of the tree must not
+ // hide a descendant the caller could have asked for directly.
+ let scope_tags = get_scope_tags(&authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec());
+ let rows = sqlx::query!(
+ r#"WITH RECURSIVE job_tree AS (
+ SELECT id, tag FROM v2_job WHERE id = $2 AND workspace_id = $1
+ UNION
+ SELECT j.id, j.tag FROM v2_job j JOIN job_tree t ON j.parent_job = t.id
+ WHERE j.workspace_id = $1
+ )
+ SELECT
+ a.path,
+ a.kind AS "kind!: windmill_common::assets::AssetKind",
+ -- Several jobs of the tree touch one asset, each recording its own
+ -- access. A job that recorded none contributes nothing rather than
+ -- erasing a sibling's, so an all-null group is the only unknown one.
+ -- Grouping here, not in Rust, is what makes LIMIT count assets: the
+ -- retention keeps up to ten job rows per asset.
+ COALESCE(bool_or(a.usage_access_type IN ('r', 'rw')), false) AS "any_read!",
+ COALESCE(bool_or(a.usage_access_type IN ('w', 'rw')), false) AS "any_write!"
+ FROM asset a JOIN job_tree t ON a.usage_path = t.id::text
+ WHERE a.workspace_id = $1 AND a.usage_kind = 'job'
+ AND ($3::text[] IS NULL OR t.tag = ANY($3))
+ GROUP BY a.path, a.kind
+ ORDER BY a.path, a.kind
+ LIMIT $4"#,
+ w_id,
+ job_id,
+ scope_tags.as_deref(),
+ // One asset past the cap is how the response learns it was cut.
+ RUN_ASSETS_CAP as i64 + 1
+ )
+ .fetch_all(&db)
+ .await?;
+
+ let truncated = rows.len() > RUN_ASSETS_CAP;
+ let assets = rows
+ .into_iter()
+ .take(RUN_ASSETS_CAP)
+ .map(|row| RunAsset {
+ path: row.path,
+ kind: row.kind,
+ access_type: match (row.any_read, row.any_write) {
+ (true, true) => Some(AssetUsageAccessType::RW),
+ (true, false) => Some(AssetUsageAccessType::R),
+ (false, true) => Some(AssetUsageAccessType::W),
+ (false, false) => None,
+ },
+ })
+ .collect();
+ Ok(Json(RunAssets { assets, truncated }))
+}
+
async fn get_flow_job_debug_info(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
@@ -1458,6 +1575,11 @@ pub(crate) async fn require_job_read_access(
}
}
+ // A path-scoped `jobs:run` token is likewise hard-restricted to the runnables it
+ // may start, ahead of every grant below — the token is handed out to run one thing,
+ // so it must not read jobs of anything else merely because its owner could.
+ require_job_within_run_scope(db, authed, w_id, job_id).await?;
+
// Fast path: you can always read a job you launched. This is also load-bearing
// for apps — a component job runs as the app policy's `permissioned_as`, but its
// `created_by` is the launching viewer, so the RLS probe below would hide it.
@@ -1583,6 +1705,95 @@ pub(crate) async fn require_job_read_access(
}
}
+/// Confines a path-scoped `jobs:run::` token to jobs of the runnables it may
+/// start. Such a token is minted per script/flow for a webhook or CI caller, which needs
+/// to start that runnable and poll the resulting job — nothing more. Without this, the
+/// by-id read routes it reaches for polling (`RUN_WHITELISTED_GET_PATHS`) would serve
+/// it the args/result/logs of any job its owner's identity can see, defeating the path
+/// confinement the scope exists to provide.
+///
+/// The scope may be satisfied by the job itself or by any of its `parent_job` ancestors:
+/// a flow step's `runnable_path` is the inner runnable's, so a `jobs:run:flows:`
+/// token inspecting its own run must still reach the steps beneath it.
+///
+/// An `apps:run|write:` scope is a start grant too, so a job an app launched
+/// (`trigger_kind = 'app'`, an app-provenance stamp `/jobs/run` cannot forge) satisfies
+/// the confinement for a token scoped to that app. Without this, a token holding both
+/// could start an app's inline-script component but not read the run back — those jobs
+/// are `AppScript`/`Preview` kinds that no `jobs:run` scope can name.
+///
+/// No-op — and no query — for every caller whose job reads are not run-confined (see
+/// `job_read_run_confinement`), which is all sessions, unscoped tokens and `jobs:read`
+/// tokens.
+async fn require_job_within_run_scope(
+ db: &DB,
+ authed: &ApiAuthed,
+ w_id: &str,
+ job_id: &Uuid,
+) -> error::Result<()> {
+ let Some(confinement) =
+ windmill_api_auth::scopes::job_read_run_confinement(authed.scopes.as_deref())
+ else {
+ return Ok(());
+ };
+ // `scope_kind` is the runnable kind a `jobs:run::` scope can name, or
+ // NULL for a job no such scope reaches directly (previews, dependency jobs,
+ // flow-inlined scripts) — those are still readable as a step of a matching flow,
+ // through their ancestors. A `singlestepflow` wraps either a script or a flow, so it
+ // projects onto the wrapped runnable the same way the batch-rerun query does.
+ let chain = sqlx::query!(
+ r#"WITH RECURSIVE chain(id, parent_job) AS (
+ SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2
+ UNION ALL
+ SELECT j.id, j.parent_job FROM v2_job j
+ JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2
+ )
+ SELECT j.runnable_path,
+ CASE
+ WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'
+ WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'
+ WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN
+ CASE WHEN COALESCE(
+ (SELECT m->'value'->>'type'
+ FROM jsonb_array_elements(j.raw_flow->'modules') m
+ WHERE m->>'id' IN ('a', 'main')
+ LIMIT 1),
+ 'script'
+ ) = 'flow' THEN 'flows' ELSE 'scripts' END
+ END AS scope_kind,
+ CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app
+ FROM v2_job j JOIN chain c ON c.id = j.id
+ WHERE j.workspace_id = $2"#,
+ job_id,
+ w_id,
+ )
+ .fetch_all(db)
+ .await?;
+
+ let runs_app = build_scope_path_predicate(authed, "apps", "run");
+ let in_scope =
+ chain.iter().any(
+ |job| match (job.runnable_path.as_deref(), job.scope_kind.as_deref()) {
+ (Some(runnable_path), Some(kind))
+ if windmill_api_auth::scopes::run_confinement_admits(
+ &confinement,
+ kind,
+ runnable_path,
+ ) =>
+ {
+ true
+ }
+ _ => job.launched_by_app.as_deref().is_some_and(&runs_app),
+ },
+ );
+
+ if in_scope {
+ Ok(())
+ } else {
+ Err(Error::NotFound(format!("Job {job_id} not found")))
+ }
+}
+
/// Self + every `parent_job` ancestor (intermediate sub-flows up to the top-level
/// root) of `job_id`, resolved via the root DB (flow lineage is not sensitive).
/// Falls back to `[job_id]` if the row is absent so callers still run their probe.
@@ -1902,7 +2113,15 @@ async fn get_job(
// same visibility as `jobs/list` (see `require_job_read_access`), or hold a public
// share link when logged out — which `public_view_grant` already established above,
// so skip re-deriving it here: this handler is what the public run page polls.
- if !has_valid_approval_token && !public_view_grant {
+ if has_valid_approval_token || public_view_grant {
+ // Both grants skip the gate below, and with it the run-scope confinement that
+ // gate carries. That confinement is a hard restriction, so re-apply it: holding
+ // an approval link for a job must not let a scoped token read one outside the
+ // runnables it may start.
+ if let Some(authed) = opt_authed.as_ref() {
+ require_job_within_run_scope(&db, authed, &w_id, &id).await?;
+ }
+ } else {
require_opt_authed_job_read_access(
&db,
&user_db,
@@ -5276,6 +5495,15 @@ pub async fn get_suspended_job_flow(
.flatten()
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
+ // The resume secret is this route's gate, so it never reaches
+ // `require_job_read_access` and the run-scope confinement that gate carries. Re-apply
+ // it against the flow whose args and status are about to be returned: holding a
+ // resume secret must not let a scoped token read a flow it may not run. Anonymous
+ // approvers are unaffected.
+ if let Some(authed) = authed.as_ref() {
+ require_job_within_run_scope(&db, authed, &w_id, &flow_id).await?;
+ }
+
let flow = GetQuery::new()
.without_logs()
.without_code()
@@ -5455,9 +5683,14 @@ pub async fn create_job_signature(
pub async fn get_flow_user_state(
authed: ApiAuthed,
+ Extension(db): Extension,
Extension(user_db): Extension,
Path((w_id, job_id, key)): Path<(String, Uuid, String)>,
) -> error::JsonResult> {
+ // Reachable by a `jobs:run` token (it is one of the by-id routes a run needs), so
+ // apply the same run-scope confinement as the other single-job reads. RLS below
+ // still governs which jobs the owner's identity can see at all.
+ require_job_within_run_scope(&db, &authed, &w_id, &job_id).await?;
let mut tx = user_db.begin(&authed).await?;
let r = sqlx::query_scalar!(
r#"
@@ -10597,7 +10830,14 @@ async fn get_completed_job_result(
_ => false,
};
- if !approval_secret_ok {
+ if approval_secret_ok {
+ // The approval secret skips the gate below, and with it the run-scope
+ // confinement that gate carries — re-apply it, as `get_job` does for the
+ // approval token. Anonymous approval access is untouched.
+ if let Some(authed) = opt_authed.as_ref() {
+ require_job_within_run_scope(&db, authed, &w_id, &id).await?;
+ }
+ } else {
require_opt_authed_job_read_access(
&db,
&user_db,
diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs
index e67420c53c..d2e0ea8e78 100644
--- a/backend/windmill-api/src/lib.rs
+++ b/backend/windmill-api/src/lib.rs
@@ -178,6 +178,7 @@ mod teams_oss;
mod token;
mod tracing_init;
mod trash;
+mod trigger_history;
pub mod triggers;
mod users;
#[cfg(feature = "private")]
@@ -280,6 +281,23 @@ async fn set_deploy_origin(
windmill_common::deploy_origin::scope(origin, next.run(req)).await
}
+/// Scope the request in the client kind it declares, so a trigger mutation can
+/// be attributed to the CLI rather than to a bare API call. Entered for every
+/// request, undeclared ones included: `TriggerSource::of_request` reads the
+/// scope's absence as "no request is being served", which is what separates a
+/// caller from a worker disabling a trigger on its own.
+async fn set_request_client(
+ req: axum::extract::Request,
+ next: axum::middleware::Next,
+) -> axum::response::Response {
+ let client = req
+ .headers()
+ .get(windmill_common::trigger_history::CLIENT_HEADER)
+ .and_then(|v| v.to_str().ok())
+ .and_then(windmill_common::trigger_history::client_from_header);
+ windmill_common::trigger_history::scope_client(client, next.run(req)).await
+}
+
#[cfg(not(feature = "tantivy"))]
type IndexReader = ();
@@ -566,6 +584,16 @@ pub async fn run_server(
(Router::new(), Router::new(), Option::<()>::None)
};
+ // Workers block on this before pulling their first job, so it is released ahead of
+ // the router tree below. `try_join!` polls this future and `workers_f` on one task,
+ // so the yield is what lets them proceed; without it they wait out the whole
+ // synchronous build. A request arriving first queues in the bound listener's backlog.
+ if let Err(e) = port_tx.send(format!("http://localhost:{}", port)) {
+ tracing::error!("Failed to send port: {e:#}");
+ return Err(anyhow::anyhow!("Failed to send port, exiting early: {e:#}"));
+ }
+ tokio::task::yield_now().await;
+
let mcp_list_tools_service = {
#[cfg(feature = "mcp")]
{
@@ -639,6 +667,7 @@ pub async fn run_server(
.nest("/folders_history", folder_history::workspaced_service())
.nest("/groups", groups::workspaced_service())
.nest("/groups_history", group_history::workspaced_service())
+ .nest("/triggers_history", trigger_history::workspaced_service())
.nest("/inputs", windmill_api_inputs::workspaced_service())
.nest("/internal_db", internal_db::workspaced_service())
.route("/labels/list", get(list_workspace_labels))
@@ -1136,6 +1165,8 @@ pub async fn run_server(
let app = app.layer(axum::middleware::from_fn(set_deploy_origin));
+ let app = app.layer(axum::middleware::from_fn(set_request_client));
+
let app = app.layer(CatchPanicLayer::custom(|err| {
tracing::error!("panic in handler, returning 500: {:?}", err);
Response::builder()
@@ -1160,14 +1191,11 @@ pub async fn run_server(
name.map(|x| format!("name={x}")).unwrap_or_default()
);
- if let Err(e) = port_tx.send(format!("http://localhost:{}", port)) {
- tracing::error!("Failed to send port: {e:#}");
- return Err(anyhow::anyhow!("Failed to send port, exiting early: {e:#}"));
- }
-
// Announce this server is ready so coordinated restarts can detect a healthy peer.
- if let Err(e) = announce_server_started(&db).await {
- tracing::warn!("Failed to announce server started: {e:#}");
+ if server_mode {
+ if let Err(e) = announce_server_started(&db).await {
+ tracing::warn!("Failed to announce server started: {e:#}");
+ }
}
let server = server.with_graceful_shutdown(async move {
@@ -1314,25 +1342,35 @@ pub async fn wait_for_db_migrations(
const SERVER_HEARTBEAT_TASK: &str = "server_heartbeat";
-/// Write a server-started heartbeat to `background_task_state` so that
-/// other instances waiting to restart can detect this server is healthy.
+/// Write a server-started heartbeat to `background_task_state` so that other
+/// traffic-serving instances waiting to restart can detect this one is healthy.
+///
+/// Only `server_mode` processes announce, since only they are peers worth waiting for:
+/// `spawn_graceful_killpill` holds a shutdown open to keep the API answered, and a worker,
+/// indexer or MCP process coming up is no evidence that it is.
+///
+/// The row is keyed per host and `owner` per process, and both halves carry weight.
+/// `INSTANCE_NAME` is random per start, so a row keyed on it never conflicts and
+/// accumulates one row per start; `owner` is what tells a peer's start from its own
+/// when processes share a host.
async fn announce_server_started(db: &DB) -> anyhow::Result<()> {
- use windmill_common::INSTANCE_NAME;
+ use windmill_common::{utils::HOSTNAME, INSTANCE_NAME};
let instance = INSTANCE_NAME.as_str();
+ let host = HOSTNAME.as_str();
sqlx::query(
"INSERT INTO background_task_state (name, value, running, owner, started_at, updated_at)
VALUES ($1, '\"started\"'::jsonb, true, $2, NOW(), NOW())
ON CONFLICT (name)
- DO UPDATE SET updated_at = NOW(), running = true, owner = $2",
+ DO UPDATE SET started_at = NOW(), updated_at = NOW(), running = true, owner = $2",
)
- .bind(format!("{SERVER_HEARTBEAT_TASK}:{instance}"))
+ .bind(format!("{SERVER_HEARTBEAT_TASK}:{host}"))
.bind(instance)
.execute(db)
.await?;
- tracing::info!("Announced server started for instance {instance}");
+ tracing::info!("Announced server started for instance {instance} on host {host}");
Ok(())
}
@@ -1362,10 +1400,10 @@ pub async fn check_any_server_started(db: &DB, not_before: chrono::DateTime not_before` (the moment a restart was initiated),
diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs
index 937ae32bf8..6971c4a715 100644
--- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs
+++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs
@@ -1199,7 +1199,7 @@ Creates a new version of an existing script when called with the same path and t
},
"execution_mode": {
"type": "string",
- "description": "Possible values: viewer, publisher, anonymous"
+ "description": "Who the app's runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Either way `anonymous`, which makes the app publicly executable, is never assumed. Possible values: viewer, publisher, anonymous"
},
"on_behalf_of": {
"type": "string"
@@ -1313,7 +1313,7 @@ Creates a new version of an existing script when called with the same path and t
},
"execution_mode": {
"type": "string",
- "description": "Possible values: viewer, publisher, anonymous"
+ "description": "Who the app's runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Either way `anonymous`, which makes the app publicly executable, is never assumed. Possible values: viewer, publisher, anonymous"
},
"on_behalf_of": {
"type": "string"
diff --git a/backend/windmill-api/src/mcp_tools.rs b/backend/windmill-api/src/mcp_tools.rs
index bba945ac54..e1931873a9 100644
--- a/backend/windmill-api/src/mcp_tools.rs
+++ b/backend/windmill-api/src/mcp_tools.rs
@@ -2,6 +2,7 @@ use axum::{
extract::{Extension, Path},
Json,
};
+use serde::Deserialize;
use serde_json::value::RawValue;
use windmill_api_auth::{check_scopes, ApiAuthed};
use windmill_common::{
@@ -11,21 +12,46 @@ use windmill_common::{
};
use windmill_store::{resources::explain_resource_perm_error, variables::get_value_internal};
-pub(crate) async fn get_mcp_tools(
- authed: ApiAuthed,
- Extension(db): Extension,
- Extension(user_db): Extension,
- Path((w_id, path)): Path<(String, StripPath)>,
-) -> JsonResult