Compare commits

...
Author SHA1 Message Date
Ruben Fiszelandrubenfiszel 8fae99fe6a chore(main): release 1.722.0 (#9493)
* chore(main): release 1.722.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-11 10:50:13 +00:00
GuilhemandClaude Fable 5 a8f1062f37 feat(frontend): show AI sessions in narrow-screen burger menu (#9523)
* feat(frontend): show AI sessions in narrow-screen burger menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): show burger menu on sessions page in narrow setup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:42:11 +00:00
centdix ce6e2f7ade fix(frontend): stop echoing draft values in global chat write tool results (#9530) 2026-06-11 11:35:09 +02:00
Ruben FiszelandClaude Fable 5 ad37eab82b copy folder labels on workspace fork, normalize cleared labels to NULL (#9529)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:07:45 +00:00
Ruben FiszelandClaude Fable 5 765f50c474 feat: folder-level label inheritance for scripts, flows and jobs (#9524)
* feat: folder-level label inheritance for scripts, flows and jobs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: use SECURITY DEFINER folder_labels() for RLS-consistent inheritance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: extend folder label inheritance to apps, resources, variables, schedules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:49:02 +00:00
Ruben FiszelandClaude Fable 5 dc60e1aa17 fix(cli): include lock-relevant script content in lock cache key (#9528)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:43:45 +00:00
hugocasaandClaude Opus 4.8 4e868062d4 fix(frontend): improve AI chat markdown and typing dots in dark mode (#9497)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:49:46 +00:00
GuilhemandClaude Opus 4.8 ab015dd44b style: lean tool-call rendering and tighter ai chat spacing (#9517)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:20:02 +00:00
hugocasaandClaude Opus 4.8 7f987e8c98 feat: add reasoning effort control and thinking display to AI chat (#9511)
* feat: add reasoning effort control and thinking display to AI chat

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: strip legacy /thinking suffix from configured model slots

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: refine reasoning effort UX and drop dynamic-capability scaffolding

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: show textless reasoning on the typing indicator

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:15:38 +00:00
centdix 61a4dd6e91 refactor: extract frontend uuid helper (#9521) 2026-06-10 15:15:04 +00:00
afddfe8445 feat(worker): #ssh directive to run a bash script on a remote SSH host (#9479)
* feat(worker): #ssh directive to run a bash script on a remote SSH host

Add a first-class `#ssh <resource_path>` bash directive that reroutes a
normal bash script to run on a remote host reached over SSH (a
jump/utility node) instead of on the worker, with full parity: typed
positional args in, structured result out, live streamed logs,
cancellation, and remote exit-code propagation.

It mirrors the existing `# sandbox <image>` precedent: the directive is
parsed in handle_bash_job and reroutes to a specialized handler that
reuses handle_child for all execution plumbing.

- windmill-common: BashAnnotations::ssh_target() parser (+ unit test)
  and the ssh_execution_enabled instance setting (off by default)
- windmill-worker: reroute hook in bash_executor + ssh_executor_oss
  shim. OSS returns a clear "enterprise feature" error; the real
  handler lives in ssh_executor_ee.rs (private feature) and is gated by
  a valid enterprise license + the instance setting.
- examples/usecase/ssh-execution-wrapper: the ssh_target resource type,
  a userland wrapper (no-license fallback), and a README documenting
  both paths and the trade-offs vs agent workers.

EE companion: windmill-labs/windmill-ee-private (ee-repo-ref.txt bumped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(worker): ssh host-key opt-in, 0600 key write, instance setting UI

* chore: update ee-repo-ref

* feat(worker): #ssh $arg form to take the ssh target from a job argument

* fix(worker): #ssh token must look like a target; $arg restricted to path strings

* fix(worker): tighten #ssh parser to exact directive; add -- ssh destination guard

* chore: update ee-repo-ref to d45b9a6cbe40f7fe5d322c850c50f64a6980e4f0

This commit updates the EE repository reference after PR #609 was merged in windmill-ee-private.

Previous ee-repo-ref: 2804f1aa8e74b3a7733aeb6f5044d5085193872a

New ee-repo-ref: d45b9a6cbe40f7fe5d322c850c50f64a6980e4f0

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-06-10 15:14:37 +00:00
GuilhemandClaude Opus 4.8 365e20410e feat(ai-chat): collapse big pastes, cap input height, escape HTML (#9487)
* feat(ai-chat): collapse big pastes, cap input height, escape HTML

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(ai-chat): concentrate paste expand/render in a ChatDraft module

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(ai-chat): step caret over paste chips as one unit

Arrow-Left/Right now jump edge-to-edge across a collapsed-paste chip
instead of crawling through the invisible token characters, and snap the
caret out if it lands inside a token. Shift extends the selection across
the whole chip; word/line jumps (alt/cmd/ctrl) are left to the browser.
Mirrors the existing atomic-deletion behavior so traversal and deletion
both treat the chip as a single object.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): atomic chip deletion on overlapping selections + plural label

A selection that partially overlapped a paste token previously hit the
collapsed-caret early-return, so the browser deleted a partial token and
left an orphaned zero-width run plus a dangling pastes registry entry.
handlePasteDeletion now widens the deletion range to cover each overlapped
token whole and drops all affected pastes entries (shared removePasteRange
helper). Strict overlap, so abutting a chip edge doesn't pull it in.

Also route line-count pluralization through a shared lineCount() helper so
a 1-line paste reads "1 line" in the conversation bubble too, not "1 lines".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): full chip atomicity via beforeinput + expand on copy/cut

Addresses review nits on PR #9487:

- Chip atomicity is no longer limited to Backspace/Delete keydown. A new
  beforeinput handler takes over any selection-spanning edit that overlaps
  a paste chip — typing over a selection, paste, drag-and-drop, and
  Backspace/Delete over a selection — and applies it to the whole token(s)
  via a shared replacePasteRange, so a partial edit can no longer leave an
  orphaned zero-width run or a dangling pastes registry entry. handlePaste
  is likewise widened so a large paste onto a chip replaces it whole. The
  keydown handler now only covers the collapsed-caret-at-boundary case
  beforeinput can't see.
- Copy/cut of a selection containing a chip now puts the expanded content
  on the clipboard instead of the chip label + its zero-width run; cut also
  removes the chip whole. This also removes the duplicate-token re-paste
  vector (the clipboard never carries a raw token anymore).
- Rename lineCount -> lineCountLabel: it returns a formatted string, not a
  count, so the name shouldn't read like an accessor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): address cubic + claude review findings on the paste feature

cubic-dev-ai findings:
- autosize: clear inline overflow-y on the uncapped path so a capped->
  uncapped toggle can't leave a stale `auto`/`hidden`.
- pasteTokens: new countLines() ignores a single trailing newline, so a
  10-line paste with a trailing \n no longer counts as 11 (off-by-one in
  shouldCollapsePaste and the chip's reported line count).
- ContextTextarea: the @-mention picker anchor now subtracts the textarea's
  own scrollTop/Left and is recomputed on internal scroll, so it stays
  pinned once the input is capped at 40vh and scrolls.

claude re-review findings:
- ContextTextarea: drag-and-drop of a selection containing a chip now puts
  the expanded content on the drag payload via ondragstart (mirroring
  copy/cut), so a dragged chip no longer orphans its token and loses the
  pasted content (or leaks the label to an external target).
- ContextTextarea: handlePasteBeforeInput now guards on e.cancelable and a
  HANDLED_INPUT_TYPES whitelist, so historyUndo/Redo (Ctrl+Z) and
  non-cancelable insertCompositionText (IME) are left to the browser
  instead of being reinterpreted as a delete / rewritten mid-composition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:58:01 +00:00
Ruben FiszelandClaude Fable 5 fddabe9c5c feat: clear conflict error + force delete when reusing a fork workspace id (#9499)
* feat: clear conflict error + force delete when reusing a fork workspace id

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: guard fork force-delete against double submit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:55:03 +00:00
GuilhemandClaude Opus 4.8 b894f783f1 feat(ai-chat): quick access to AI prompt settings from chat (#9508)
* feat(ai-chat): quick access to AI prompt settings from chat

Add a cog next to the AI model selector in the chat that opens a dropdown
to edit the current chat mode's user (localStorage) and workspace (DB
ai_config) AI prompts, reusing the shared AIPromptsModal. Workspace prompt
editing is admin-only; non-admins get a read-only view with an info Alert.
The dropdown and modal footer include an admin-only deep-link to the full
AI settings page, opening in a new tab.

Workspace save re-applies the saved custom_prompts onto the store after the
ai_config round-trip, since effective_ai_config falls back to the instance
config (and would drop them) when the workspace has no providers of its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): snapshot mode when opening prompt settings modal

Prompt edits were keyed off the live chat mode (a $derived of
aiChatManager.mode). If the chat mode changed while the modal was open,
save/reset/hasChanges and the modal's target mode would follow the new
mode and write to the wrong key. Capture the mode into an activeMode
snapshot at open time and use it throughout the modal lifecycle.

Identified by cubic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): base-prefix AI settings links and reset hasChanges after save

- Prefix the AI-settings deep-links (dropdown item + modal footer) with
  {base} so they resolve under a deployment base path instead of 404ing.
- After a successful save, sync customPrompts to the trimmed value so
  hasChanges flips back to false (Save/Reset disable, Reset no longer
  snaps the textarea).

Identified by Claude review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): gate workspace prompt editing on workspace having own providers

When a workspace has no AI providers of its own (it uses instance defaults),
the backend never makes workspace custom_prompts effective — get_copilot_info
returns the instance config verbatim. A workspace prompt saved in that state
would be dead config that silently disappears on reload, and the earlier
re-apply hack also transiently replaced instance prompts in copilotInfo.

Mirror the settings page (AISettings.svelte): detect the workspace's own
providers in the same getSettings fetch used to seed the prompt, and when there
are none, surface the workspace prompt read-only with an explanatory Alert
(new readOnlyReason prop on AIPromptsModal) instead of an editable field.
Since editing is now gated to workspaces with their own providers,
effective_ai_config equals the saved config, so the re-apply is reverted to a
plain setCopilotInfo(effective).

Identified by Claude review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): await async save before closing prompts modal

AIPromptsModal.handleSave called onSave() without awaiting, then closed the
modal immediately — so the workspace save round-trip was still in flight when
the modal dismissed, with no loading state and any error toast landing after
the modal was gone.

Make handleSave await onSave (now typed to allow a Promise), show a loading
state on Save while in flight, and keep the modal open if the save throws.
AIChatSettingsMenu.save() re-throws on failure so the modal stays open. The
synchronous user-prompt path and existing callers are unaffected.

Identified by Pi review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:54:45 +00:00
hugocasaandClaude Opus 4.8 0b178437ce fix(frontend): clarify trigger filters match the message parsed as JSON (#9516)
* fix(frontend): clarify trigger filters evaluate decoded JSON, not base64

The filter description in TriggerFilters didn't explain that filters are
evaluated on the original decoded JSON payload, while a base64-encoding
trigger (Kafka) still delivers the payload to the runnable as a base64
string. Users saw base64 in their scripts and assumed filters couldn't
reference JSON keys.

Adds a `payloadBase64Encoded` prop (set by the Kafka editor) that appends
a sentence clarifying the runnable receives base64 while filter keys
reference the original JSON structure. WebSocket triggers deliver the raw
message string, so the base64 caveat is intentionally not shown there.

Fixes WIN-2029

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): reword trigger filter help to "parsed as JSON", not "decoded"

Nothing is base64-decoded during filtering: WebSocket parses the text
frame directly, Kafka converts the message bytes to UTF-8 then parses.
The base64 form only exists on the delivery path to the runnable (Kafka,
V2). Reword to "filters match against the message parsed as JSON" and, for
base64 triggers, clarify filters run on the message before encoding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): correct filter-key example to match backend semantics

Review follow-up: the "data.status" example implied dot-path filter keys,
but the backend (SupersetVisitor in filter.rs) matches keys literally
against top-level JSON fields — "data.status" would never match a nested
object. Reword to state keys match top-level fields and nested matching
is done via an object value (key data, value {"status": "active"}).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:54:27 +00:00
Ruben Fiszelandwindmill-internal-app[bot] 3c3f15722f feat: prefer idle worker pods on k8s autoscaling scale-in via pod-deletion-cost (#9515)
* docs: investigate pod-deletion-cost for k8s autoscaling scale-in (WIN-2028)

* docs: correct worker_instance aggregation claim per review

* docs: drop investigation doc in favor of implementation

* chore: bump ee-repo-ref for pod-deletion-cost autoscaling scale-in

* chore: update ee-repo-ref to 80c39bf7f3bfeda4cf0974ce32826f53affd9574

This commit updates the EE repository reference after PR #610 was merged in windmill-ee-private.

Previous ee-repo-ref: 51f79d4a49dd6491f4809f3edcb3919571719da7

New ee-repo-ref: 80c39bf7f3bfeda4cf0974ce32826f53affd9574

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-06-10 14:53:28 +00:00
Diego ImbertandClaude Fable 5 3119e16ed8 feat: prompt browser confirmation on page exit with unsaved changes (#9503)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:02:45 +00:00
hugocasaandClaude Opus 4.8 676e907ea6 docs: replace dead 0x0.st with gh-based PR screenshot recipe (#9512)
* docs: replace dead 0x0.st with gh-based PR screenshot recipe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: harden PR screenshot recipe (filename, secrets, CI fallback)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:01:53 +00:00
centdixandClaude Opus 4.8 1d4328877f fix: make default chat model optional in AI settings (#9514)
* fix: make default chat model optional in AI settings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: stop auto-seeding default chat model on provider enable

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:01:28 +00:00
Ruben Fiszel a6a5600833 feat(flow): support worker tag override on AI agent steps (#9513)
* feat(flow): support worker tag override on AI agent steps

* refactor: drop ineffective tag passthrough in nested agent tool path

* chore: regenerate openflow-derived system prompt artifacts
2026-06-10 13:54:05 +00:00
Ruben FiszelandClaude Fable 5 cf9ad54181 feat: workspace protection rule to restrict anonymous app deployment (#9509)
* feat: restrict anonymous app execution mode to admins

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: add regression test for anonymous app admin gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: gate anonymous app mode behind workspace protection rule

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: lock app row on anonymous-mode check, fail closed while rules load

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:47:19 +00:00
Diego ImbertandClaude Opus 4.8 7fc5340da3 fix(frontend): allow copy/paste shortcuts inside ConfirmationModal (#9505)
The modal's window keydown-capture handler called preventDefault() and
stopPropagation() on every keystroke while open, which swallowed Cmd/Ctrl+C
and Cmd/Ctrl+V (and blocked typing in any child input). Only intercept
Enter/Escape without modifiers and let all other keys through.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:44:15 +00:00
Ruben Fiszel 08da7a121b fix(nsjail): make ansible collections mount non-mandatory (#9510) 2026-06-10 12:54:34 +00:00
Ruben Fiszel dc368a9669 fix(nsjail): make ansible uv tools mount non-mandatory (#9507) 2026-06-10 11:40:13 +00:00
Ruben FiszelandClaude Fable 5 53da8ac148 docs(cli): add Windmill documentation section to generated AGENTS.cli.md (#9506)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:10:27 +00:00
hugocasaandClaude Opus 4.8 c80c6d8fcd fix(frontend): enable Apply button when env vars change in worker group config (#9501)
Adding or editing environment variables in the worker group config drawer
did not enable the "Apply changes" button. `hasChanges` compares the original
`config` prop against the local `nconfig` copy, but env var edits only mutate
the local `customEnvVars` array — they aren't synced into
`nconfig.env_vars_static`/`env_vars_allowlist` until the Apply handler runs.
This left the button stuck disabled despite real changes.

Add a `hasEnvVarChanges` derived that reconstructs the original env vars from
`config` and compares them to the current `customEnvVars`, and include it in
the Apply button's disabled condition.

Fixes WIN-2023

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:28:53 +00:00
Ruben Fiszel 49561f9e49 update claude actions to fable 5 2026-06-09 17:54:17 +00:00
centdixandClaude Opus 4.8 cfe5119035 feat(ai): add list_runs and get_job_logs tools to global chat mode (#9488)
* feat(ai): add list_runs and get_job_logs tools to global chat mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai): always suppress ansi hint in get_job_logs, drop misnamed param

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai_evals): add global list_runs and get_job_logs eval cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(ai): trim get_job_logs description and format global core

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai): surface list_runs/get_job_logs output as tool result

The tools set showDetails but never set message.result, so the details panel rendered "No result yet" even on success. Set result in setToolStatus (logs go in result for get_job_logs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:34:36 +00:00
Ruben FiszelandClaude Opus 4.8 4c22e3b712 fix: inherit container NO_PROXY into MITM tracing proxy job exclusions (#9492)
When HTTP request tracing is enabled, the NO_PROXY injected into traced jobs
was built solely from the no_proxy_hosts instance setting, ignoring the
worker container's own NO_PROXY. Enabling tracing therefore silently dropped
every exclusion an operator had already configured at the container level,
funneling those hosts into the MITM proxy (and on to any upstream corporate
proxy). The upstream-relay side already honored the container NO_PROXY; this
makes the injected-into-jobs side symmetric by merging both sources.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:33:01 +00:00
136c88a231 docs(skills): decouple safe local commands from destructive sync push (#9467)
* docs(skills): decouple safe local commands from destructive sync push

The schedules, triggers, and resources skill templates lumped every CLI
command under a blunt "do NOT run them yourself" directive. This conflated
two very different risk profiles and forbade the agent from running even
read-only/local commands, creating needless friction.

Align these three with the nuanced policy flow-cli.md already uses: keep
`wmill sync push` defensive (it deploys and can be destructive to remote
state — only run when the user explicitly asks to deploy/publish/push),
while letting read-only commands (`sync pull`, `schedule`, `resource
list`) be run freely. Regenerated auto-generated skills + skills.gen.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): warn that sync push is destructive in dry-run output

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): clarify sync pull mutates local files, not read-only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: centdix <farhadg110@gmail.com>
2026-06-09 14:05:50 +00:00
Ruben Fiszelandrubenfiszel 66c0334e70 chore(main): release 1.721.0 (#9480)
* chore(main): release 1.721.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-09 08:14:02 +00:00
Ruben FiszelandClaude Opus 4.8 c258928ab6 fix(cli): reconcile case-only path drift during sync on case-insensitive filesystems (WIN-2020) (#9485)
* fix(cli): reconcile case-only path drift during sync on case-insensitive filesystems

Windmill paths are case-sensitive, but Windows (and the default macOS
setup) use case-insensitive filesystems. The real-world failure behind
WIN-2020 is not a user authoring both f/Caps and f/caps — it is a single
capitalized folder whose on-disk casing silently drifts (Windows stores
and reports whatever case the directory was first created with,
regardless of the server's path). The diff then sees the drifted local
path as a brand-new item and emits a destructive "delete f/Caps +
add f/caps" pair, so a capitalized folder appears to vanish and a
lowercase clone shows up out of nowhere — and a push can clobber the
real server item.

Fix: on a case-insensitive filesystem, reconcile case-only drift before
diffing. The server's path casing is authoritative, so compareDynFSElement
now rewrites local keys that differ from a remote key only by case to the
server's casing (canonicalizeCaseInsensitiveKeys), making the diff treat
them as the same item. Case-insensitivity is auto-detected by probing the
sync directory, with a WMILL_CASE_INSENSITIVE_FS=true/false override to
force Windows behaviour (or emulate it for tests / cross-platform repos)
on any host. Reconciled paths are summarized in a single info line.

Genuinely unrepresentable collisions — two DISTINCT server paths that
differ only by case — cannot be canonicalized to one target; those are
detected and warned about on every platform so a case-sensitive-Linux
author learns their tree won't round-trip for a Windows/macOS teammate.

Tests:
- Pure unit tests for findCaseInsensitiveCollisions,
  canonicalizeCaseInsensitiveKeys and summarizeCaseRewrites (platform
  independent).
- An end-to-end drift test that runs on BOTH CI jobs: on the Windows
  runner it exercises the real case-insensitive NTFS + auto-probe; on
  Linux it reproduces the drift via rename, asserts the destructive
  phantom appears without the fix, and asserts a clean no-op push with
  the fix forced on.

Fixes WIN-2020

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): canonicalize local-only descendants of drifted folders; dedupe nested case collisions

Address two review findings on the WIN-2020 case-insensitive sync fix:

P1 (correctness): canonicalizeCaseInsensitiveKeys previously only rewrote
local keys with an exact full-path remote match. A brand-new local file
under a drifted folder (e.g. adding f/caps/New.ts when the server has
f/Caps but no f/caps/New.ts) had no exact match, so it kept its lowercase
casing and push uploaded it as-is — recreating f/caps beside f/Caps and
reintroducing the very collision the fix prevents. Canonicalization is now
segment-by-segment against a trie of remote paths, so local-only
descendants inherit the longest unambiguous server folder casing. A segment
is only adopted when the server casing is unambiguous; at the first
ambiguous/unknown segment the remainder keeps local casing. The original
key's separator style is preserved so rewritten keys still round-trip.

P2 (nit): findCaseInsensitiveCollisions reported the folder group AND a
nested per-file group when case-variant folders held same-named files,
inflating the "Found N path(s)" count. It now reports only the shallowest
clash (drops a group whose ancestor prefix is itself a collision).

Tests: add unit coverage for the new-file-under-drifted-folder rewrite, the
stop-at-first-unguided-segment behavior, and shallowest-only collision
reporting; extend the e2e drift test to assert a new item added under the
drifted folder is pushed under the server's folder casing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:09:04 +00:00
Ruben FiszelandClaude Opus 4.8 92c21bbe65 fix: drop archived items from fork compare (spurious 'not visible' warning) (#9481)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:45:18 +00:00
Ruben FiszelandClaude Opus 4.8 5f41ddd3a5 fix: require auth to view approval details when user_auth_required (#9482)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:26:19 +00:00
GuilhemandClaude Opus 4.8 b0b330c786 feat: deployed↔draft compare + AI-session draft bar (#9435)
* feat: deployed↔draft compare for current workspace + session draft bar

Add a "Deployed ↔ draft" comparison alongside the existing fork-vs-parent
compare flow, and surface drafts in the AI session UI.

- Merge the fork-direction toggle (Deploy to parent / Update current) and
  the new deployed↔draft mode into one 3-way CompareModeToggle, rendered
  inside the comparison card. Hidden in non-fork workspaces (draft only).
- CompareDrafts: list/deploy/discard server drafts (scripts, flows, apps
  incl. raw apps) via shared WorkspaceDeployLayout.
- Session draft bar (SessionDraftBar) mirrors the fork bar, only visible
  when drafts exist; its diff button opens the shared read-only diff
  drawer extracted as WorkspaceDiffDrawer (ForkDiffDrawer + DraftDiffDrawer
  are thin wrappers over it).
- WorkspaceDraftsBanner: home banner linking to draft review.
- Backend: GET /drafts/count endpoint for the draft-count badge.
- Raw app draft deploy (rawAppDeploy.ts) + vite /ui_builder proxy headers
  so the bundler iframe loads cross-origin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: refine draft/fork compare toggle UX

Follow-up polish on the merged deploy/draft compare control:

- Relabel the draft toggle to "Deploy draft (N)" and show per-direction
  counts on all three toggle buttons (deployable / updateable / drafts),
  suppressed when zero. Counts are computed page-side so they persist in
  draft mode too.
- Warn before deploying to the parent when the fork has undeployed drafts
  ("Only deployed versions in this fork can be sent to {parent} …") with a
  one-click link to the draft view; milder note in the update direction.
- Show an empty-state message per direction ("Nothing to update — this
  fork is up to date with {parent}") instead of a table of greyed,
  non-actionable rows; hide the deploy/update button in that case.
- Drop the standalone "Pending drafts" info alert from the draft list.
- Align the fork "Show diff" button to the non-deprecated Button API
  (unifiedSize, onClick, startIcon) so it matches the draft one; mark
  "Discard draft" destructive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: link compare row titles to the item editor

- Render each compare row title (fork and draft) as a link that opens the
  item in a new tab, scoped to the current workspace (raw apps route to
  /apps_raw/edit), matching the AI-session diff drawer: target=_blank, hover
  underline + ExternalLink icon, click stops row-selection propagation.
  Kinds without an editor stay plain; the fork rename markup is preserved.
- Drop the "Kind → name" prefix from draft rows — that arrow reads as the
  rename visual and the kind is already shown by the row icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: clickable rows + multi-select in deploy layout

- Make deploy-layout row cards selectable on click via an opt-in
  `selectOnRowClick` prop on the shared Row (default off, other tables
  unaffected); clicks on the checkbox, title link and action buttons are
  ignored. Adds role/tabindex + Enter/Space keyboard support.
- Support multi-select with modifier keys like classic list pickers:
  Shift+click selects the contiguous range from the anchor row; Cmd/Ctrl
  (and plain) click toggles a single row. select-none avoids text
  highlighting on shift-click.
- Turn the "Select all" text into a <label> associated with its checkbox
  so clicking the text toggles it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: select all drafts by default in draft compare

Drafts now load pre-selected (deploy-all is the common intent); guarded so
a reload after a deploy doesn't re-select the items left behind. Mirrors
CompareWorkspaces' default auto-selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: show diff for draft-only items stored without a draft row

A draft_only flow/script/app whose content lives in the entity row itself
(created via create*(draft_only: true), no separate draft-table row — like
u/admin/new) returns draft == null from get*ByPathWithDraft. getDraftDiffValues
passed that null through, so the diff "after" side was empty and nothing
rendered. Fall back to the row's own value as the draft content when draft is
null (deployDraft already did this), fixing both the compare-page DiffDrawer
and the session bars' WorkspaceDiffDrawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: shared diff button across session bars + bar spacing

- Extract SessionDiffButton (variant=default, ± DiffIcon, count, "Open diff"
  title) and use it for the diff-drawer trigger in both the fork bar and the
  draft bar, so they're identical. Drop the icons from both "Review" buttons.
- Add gap-1 (4px) between the fork bar and draft bar when both are visible
  (flex wrapper; single in-flow root per bar, drawer is portalled — no stray
  gap when only one shows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: deploying a new (draft-only) flow or app

A draft_only flow/app already has an entity row (created via
create*(draft_only: true)), so deployDraft's createFlow/createApp rejected it
with 400 "already exists". Use updateFlow/updateApp instead — a listed draft
always has a row, and update promotes a draft_only entity to a real deployed
version (clearing the flag), like the editor does. Scripts were unaffected
(createScript + parent_hash makes a new version).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: refresh fork comparison after deploying/discarding a draft

Deploying a draft promotes it to the workspace's deployed version, changing
the fork comparison (ahead/behind vs parent) — but the compare page only
re-fetched it on workspace change, so the deploy/update toggle counts and the
CompareWorkspaces tab went stale. CompareDrafts now fires onChanged after a
successful deploy/discard; the page rewires it to refresh the comparison and
draft count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: stop draft-count effect from freezing the AI session page

ensureDraftCount cleared its dedupe key on error; since the caller is a reactive $effect (SessionDraftBar), a persistently-failing countDrafts spun the effect into an infinite retry loop that flooded the console and froze the tab. Claim the key before awaiting and keep it set on failure; refresh*() still forces a re-fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: correct draft count and refresh compare counts after actions

count_drafts now counts deployable drafts (draft_only OR has-a-draft-row across script/flow/app), matching the CompareDrafts list, instead of raw draft-table rows which miss new draft-only items. CompareWorkspaces and CompareDrafts fire onChanged so the compare page re-fetches the comparison and draft count after deploy/update/discard, keeping the toggle badges in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: session draft bar shows a fresh count on every (re)open

The runtime persists across client-side navigation, so the deduped
ensureDraftCount() kept a stale count (e.g. a 0 cached before a draft was
created) when a session was re-opened — the bar stayed hidden even though
the server count was >0.

Force one fresh fetch per mount from a non-reactive onMount via
refreshDraftCount(workspace) (which now takes the workspace so it works
before the dedupe key is set). The reactive $effect keeps using
ensureDraftCount: refreshDraftCount reads loadingDraftCount ($state), so
calling it from an effect would track-and-mutate that state into an
infinite fetch loop — ensureDraftCount's plain-key early-return avoids it.
ensureDraftCount also now releases its key after a 5s backoff on failure so
a transient countDrafts error retries instead of leaving the bar stuck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: make the draft count a single deep Workspace Drafts module

The Draft Count was computed four ways (backend count_drafts SQL, the
CompareDrafts list filter, a bespoke sessionRuntime cache, and the compare
page state) that drifted — the root cause of the unreliable count, the
stale-on-reopen bug, and the effect-loop freeze.

Introduce one module (workspaceDrafts.svelte.ts):
- getDraftItems(ws) lists the deployable Draft Items once; count ≡ list length,
  never a separate query.
- useWorkspaceDrafts(() => ws) is a component-scoped runed resource (fetches on
  mount + ws change, no persistent cache → fresh on every (re)open).
- invalidateWorkspaceDrafts(ws) refreshes mounted consumers; deployDraft/
  discardDraft self-invalidate, so callers never reason about staleness.

Rewire every reader to it (SessionDraftBar, CompareDrafts, DraftDiffDrawer,
WorkspaceDraftsBanner, compare page) and delete the sessionRuntime draftCount
apparatus (key + loading flag + 4 methods + effects + backoff). With no caller
left, remove the count_drafts endpoint (handler, route, openapi, sqlx cache,
generated client) — drafts.rs/openapi return to their main state. Record the
draft vocabulary in CONTEXT.md.

A single GET /w/{ws}/drafts/items endpoint can later replace getDraftItems'
three list calls behind the unchanged seam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: warn before deploying a draft based on an outdated version

When a newer version is deployed while a draft exists (git-sync/CLI deploys preserve drafts via skip_draft_deletion), the compare/deploy-drafts page now flags the draft as Outdated and gates deploy behind an override confirmation with a diff — instead of silently clobbering the newer version. Staleness is read from the draft's base version: scripts already store parent_hash; flows/apps now record a draft_base_version sidecar on save.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: drop redundant /ui_builder proxyRes hack (superseded by #9433)

main's global configure-response-headers plugin now runs with enforce:'pre'
and sets COOP/COEP/CORP on dev responses (#9433), so the per-proxy proxyRes
override is no longer needed. Revert the /ui_builder block to main's headers
form — vite.config.js now matches main with no branch-specific change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(sessions): keep draft count reactive to preview/chat deploys

Invalidate the Workspace Drafts resource at every frontend deploy seam
(ScriptEditorView / FlowEditorView / RawAppEditorView onDeploy + onSaveDraft)
so user-driven deploys from the Preview panel update the count immediately,
and refresh SessionDraftBar on the same coarse signals SessionForkBar uses
(AI turn-end + tab refocus) to cover chat-driven deploys that happen
server-side and never surface as frontend calls.

Also: always show the draft toggle count including (0) on the compare page,
drop the header/content separator line in both compare cards, and derive the
"Deploy N drafts" footer count so it stays reactive after discard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: address branch review findings

- WorkspaceDraftsBanner: use the modern Button API (variant/unifiedSize/onclick)
  instead of the deprecated size/color/on:click triad; drop "pending" from the
  banner copy to match CONTEXT.md vocabulary.
- WorkspaceDeployLayout: make Cmd/Ctrl-click distinct from a plain click.
  Plain row click now selects only that row (classic file-picker), Cmd/Ctrl
  toggles, Shift extends the range, and the checkbox still plain-toggles.
  Adds an onSelectOnly callback wired in CompareDrafts/CompareWorkspaces.
- WorkspaceDiffDrawer: document why the file filter is a raw input (bespoke
  keyboard-nav integration the design-system inputs can't express).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* revert: drop draft version-gating (stale-draft warning)

Remove the "deploying an outdated draft would override a newer version"
guard. It's a rare edge case and will be handled properly by conflict
resolution in a follow-up PR.

- CompareDrafts: drop staleMap/computeStaleness, the TOCTOU pre-deploy
  re-check, the "Outdated" badge, the override-in-diff button, and the
  "Newer version deployed" confirmation modal; deploySelected is now the
  plain deploy.
- utils_draft_deploy: remove getDraftStaleness/DraftStaleness and the
  draft_base_version strip.
- FlowBuilder / AppEditorHeader / RawAppEditorHeader / AppJsonEditor: stop
  injecting draft_base_version into draft saves — these editor paths are
  back to matching main, shrinking the PR's blast radius.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: unify home banner CTAs on the modern Button API

Both the Workspace Drafts banner and the sibling Fork banner now use
variant="default" unifiedSize="sm" onclick, so the two CTAs on the home
page render identically and neither uses the deprecated size/color/on:click
props.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(compare): show draft deploy direction badge inside forks

Mirror the fork compare header's "from → into" badges on the Deploy-draft
tab: "deploy: draft → into: <fork>". Makes it explicit that deploying a
draft promotes it within the fork (deployed↔draft), not up to the parent.
Only rendered inside forks, where the parent could otherwise be confused
with the deploy target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(compare): address PR review — dedup drafts resource + shared link

- De-dupe the compare page Workspace Drafts fetch: the page owns the single
  resource and passes draftItems/draftsLoading into CompareDrafts (was mounting
  a second resource → 6 list calls; now 3).
- Prune transient deploymentStatus for items dropped from the list (no unbounded
  growth, no stale 'deployed' suppressing a re-drafted row).
- Type getDraftItems' list fields via a narrow DraftListEntry (drop Array<any>).
- Clear comparison catch-up timers on unmount (onDestroy).
- Extract shared ExternalEditLink.svelte; use it in CompareDrafts,
  CompareWorkspaces, WorkspaceDiffDrawer (was a near-verbatim <a> block x3).
- Note conflicts intentionally count in both toggle directions; drop a stray
  blank line in sessionRuntime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(compare): show draft summary renames via shared item-summary component

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(compare): address round-2 PR review

- Point the fork-compare edit link at the workspace the item actually lives
  in: a parent-only row (absent in the fork) would 404 if linked into the
  fork, so link it into the parent instead.
- Replace the bespoke raw <button class="underline">Deploy drafts</button> in
  the undeployed-drafts alert with a design-system Button (variant=subtle).
- Drop the stray Prettier reflow in sessionRuntime (restore to match main).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(compare): warn on fork items with a pending draft

In the fork compare list, items that are deployed *and* have a pending
draft (has_draft) now:
- show a yellow "+Draft" badge (AlertTriangle), rendered before the
  New/status badges, with a per-direction tooltip explaining that
  deploying/updating moves the deployed version, not the draft;
- are excluded from the default selection (still manually selectable);
- trigger a confirmation modal if explicitly selected and deployed/updated,
  listing the affected paths.

The signal comes from the page's existing fork drafts resource (a
kind:path Set passed down) — no new fetch, no backend change. Also rename
the undeployed-drafts alert CTA from "Deploy drafts" to "See drafts".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(compare): rename page to "Compare & Deploy"

Update both the page heading (PageHeader) and the browser-tab title.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(compare): multi-select rows by default (no modifier)

In the shared WorkspaceDeployLayout (fork + draft lists), a plain row
click now toggles the item in/out of the selection instead of replacing
the whole selection with it. Removed the modifier-based selection
entirely: the now-dead onSelectOnly path and its two call sites, plus
shift+click range selection (and its anchor/isPickable helpers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(table): don't toggle row selection on keyboard child activation

Row's onkeydown selection handler lacked the interactive-child guard that
handleRowClick already had, so pressing Enter/Space on a checkbox, action
button, or title link both activated the child and toggled the row's
selection. Extract a shared fromInteractiveChild() guard and apply it in
handleRowKeydown, mirroring the click path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(fork-banner): show draft CTA when fork is up to date

When a fork has no changes vs its parent ("Everything is up to date") but
has pending drafts, the banner now mirrors the non-fork drafts banner:
the status text becomes "This workspace has N draft(s)" and the button
becomes "Review & deploy drafts", linking to the compare page in draft
mode. When the fork has real ahead/behind diffs, the existing status and
buttons are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): honor renamed draft paths + raw-app draft fixes

Address the Codex review:
- Draft deploy now uses the draft payload's path for scripts, flows and raw
  apps (keeping the URL path as the existing item key), so a rename in a
  draft deploys to the new path instead of silently staying at the old one.
- DraftDiffDrawer maps raw apps to the `raw_app` kind so their row edit
  links open the raw-app editor, not the legacy app editor.
- ScriptEditorView.restoreDeployed invalidates the workspace drafts after
  deleting the draft, so the session draft-bar count drops immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): guard showDiff race, tree label, mode fallback

Address the cubic review:
- CompareDrafts.showDiff uses a monotonic request token so two quick "Show
  diff" clicks can't let a slow earlier fetch overwrite a faster later one.
- WorkspaceDiffDrawer.buildTree labels a 2-segment path with its leaf name
  (parts[1]) instead of the full scope key.
- The compare page only resolves ?mode=draft immediately; ?mode=fork (and
  an absent mode) defer to the isFork-aware effect, which falls back to
  draft for non-fork workspaces instead of stranding them on the fork UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: remove CONTEXT.md from the PR

Drop the root CONTEXT.md domain glossary and the lone comment pointer to
it in workspaceDrafts.svelte.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): send custom_path on raw-app draft deploy

A raw-app draft that changes or clears its custom route was silently
dropped on deploy from the compare page: updateAppRaw omitted custom_path,
so the backend preserved the old route. Send the draft's custom_path on
update — matching the fork deploy path (which spreads the full app,
custom_path included) and the createAppRaw branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sessions): refresh draft count on raw-app session save-draft

The script/flow session editors invalidate the workspace drafts on
save-draft, but the raw-app editor only did so on deploy. Thread an
onSaveDraft callback through RawAppEditor → RawAppEditorHeader and call
invalidateWorkspaceDrafts from RawAppEditorView, so saving a raw-app draft
in an AI session updates the SessionDraftBar count immediately (and the bar
appears when the count was zero).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): honor renamed paths, draft triggers & paginate inventory

Address the Codex review:
- Draft deploy honors the draft's renamed path for scripts/flows/raw apps
  (keeping the URL path as the existing item key).
- Script/flow draft deploy now deploys draft_triggers via the shared
  deployTriggers, instead of silently dropping them with the draft.
- rawAppDeploy sends custom_path admin-gated on update (admin: value/'' to
  clear; non-admin: undefined) so non-admins don't hit RequireAdmin.
- getDraftItems pages through listScripts/listFlows/listApps so drafts past
  the first page are included in the count, banners, drawer and deploy list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): admin-gate custom_path on visual-app draft deploy

The visual-app branch of deployDraft sent custom_path unconditionally on
updateApp, so a non-admin deploying an app draft for an app with a custom
route hit RequireAdmin. Mirror AppEditorHeader and the raw-app path: admins
send the draft's custom_path ('' clears), non-admins send undefined so the
backend preserves the existing route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(raw-app): save initial draft directly when path is known

In the AI-session preview, a never-deployed raw app has newApp=true but a
known path, so saveDraft opened the "Initial draft save" path-picker drawer
— which is gated on `appPath == ''` and therefore never rendered, making
Save draft silently do nothing. Branch the new-app case on appPath: pick a
path via the drawer only when none is chosen yet; otherwise call
saveInitialDraft() directly. saveInitialDraft now also toasts and fires
onSaveDraft so the session draft-bar count refreshes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): preserve deployed custom_path on visual-app draft deploy

The visual-app draft value usually omits custom_path, so the admin branch's
`d.custom_path ?? ''` sent an empty string, which the backend treats as
"clear the route" — an admin deploying a content-only draft would wipe the
app's existing custom route. Fall back to the deployed route
(`r.custom_path`) when the draft omits it; an explicit '' still clears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:24:23 +00:00
Ruben Fiszelandrubenfiszel a3740d571a chore(main): release 1.720.0 (#9464)
* chore(main): release 1.720.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-08 18:02:10 +00:00
hugocasaandClaude Opus 4.8 e8e0701a36 feat(api): add endpoint to update token label (#9474)
* feat(api): add endpoint to update token label

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): prevent renaming the session token label

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): restrict token-label edits to user tokens, not just session

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): edit token label in the edit modal instead of inline

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): reject relabeling tokens to reserved system-token names

Centralize the is_user_token classifier in windmill-common and reuse it
to reject labels colliding with system-token namespaces (ephemeral*,
debugger-token, mcp-oauth-*), not just session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): match ephemeral label case-insensitively and cap label length

Align the canonical is_user_token, the SQL guard and the frontend mirror on
a case-insensitive `ephemeral` match (so a token can't be relabeled to a
casing the backend allows but the UI hides), reject labels over the
VARCHAR(1000) column limit with a 400, and add unit tests for is_user_token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:33:31 +00:00
5d0ef7dfd9 fix: center auth0/okta icons and respect currentColor (#9457)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-06-08 16:19:54 +00:00
GuilhemandClaude Opus 4.8 6d522b3989 fix: refresh session editor preview on breadcrumb target switch (#9475)
* fix: refresh session editor preview on breadcrumb target switch

Consolidate the three session editor views into a SessionEditorTarget deep module that remounts the heavy editor on a data-ready target swap ({#key slot.loadedPath}), so stale mount-time state (e.g. Path.svelte's settings-panel path) re-derives. Adds LoadSlot to the runtime and a useUserDraftSync composable + per-kind codecs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: flush pending session draft write on target switch

A breadcrumb target swap (or unmount) within the 150ms outbound debounce window cleared the pending UserDraft write instead of flushing it, dropping the last edits. Scripts previously saved immediately so this was a regression from the new uniform debounce; flow/raw_app already had the latent drop. A dedicated path/workspace-scoped effect now flushes the pending write on switch/unmount without disturbing the debounce during a typing burst. Also refreshes a stale loadScript comment that named removed symbols (addresses PR review nits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:19:19 +00:00
hugocasaandClaude Opus 4.8 192574ab8f fix(forks): keep trigger/schedule operational state owned by the parent - WIN-2019 (#9476)
* fix(forks): defer trigger/schedule state to parent for clean git merge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(forks): read parent trigger/schedule state on non-RLS pool for complete substitution

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(forks): read schedule fork-ness on non-RLS pool; clarify mutator-rule wording

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:18:12 +00:00
Ruben FiszelandClaude Opus 4.8 76c0d970a1 fix(oauth): persist refreshed token through configured secret backend (#9471)
The lazy on-fetch OAuth token refresh persisted the new access token with a raw
`UPDATE variable SET value = <db-encrypted>`, bypassing the secret-backend
abstraction. With an external secret backend (AWS Secrets Manager / Azure Key
Vault / Vault), secret reads resolve through the backend and ignore
`variable.value` entirely, so refresh advanced `account.expires_at` and updated
Postgres but never wrote the new token to the external store. Every read that
did not itself trigger a mint kept serving the frozen connect-time token, which
expired ~1h after connect (RefreshError on Google clients).

`windmill-oauth` can't depend on `windmill-store` (circular), so variable
persistence moves out of `refresh_token{,_for_account}` (which now only exchange
the token + update the `account` row and return the new token) into the
`windmill-store` callers, via a new `store_oauth_token_value` helper that writes
through the configured backend and stores the returned value (encrypted blob for
the DB backend, `$...:` marker for external backends) in `variable.value`.

If persisting the refreshed token fails (more likely now that it can be a
network write to an external backend) after the account was committed fresh,
`store_oauth_token_value` resets `expires_at` to the past and records
`refresh_error` — looking the account up via `variable.account` — so the next
fetch retries instead of serving the stale token for the whole token lifetime.

Also add `windmill-store/tests/oauth_refresh_secret_backend.rs`, an opt-in e2e
regression suite (RUN_SECRET_BACKEND_E2E / RUN_AWS_SM_TESTS) covering database
and external (AWS SM via LocalStack) backends plus the self-healing reset.
Verified against Postgres + LocalStack: 3 passed.

EE companion (oauth_refresh_ee.rs: 3 refresh paths) merged via #607; this OSS
half completes the fix (ee-repo-ref already at EE main 481ea7f).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:52:23 +00:00
Ruben FiszelandClaude Opus 4.8 fa86c62b66 fix(frontend): use ban icon for canceled jobs instead of hourglass (#9478)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:42:07 +00:00
centdixandwindmill-internal-app[bot] 3bc5800197 feat: allow private MCP server URLs (#9470)
* feat: allow private MCP server URLs

* docs: remove private MCP server URL doc

* fix: apply MCP URL opt-in to OAuth handlers

* fix: update EE ref for MCP OAuth redirects

* fix: preserve MCP OAuth client timeout

* chore: update ee-repo-ref to 481ea7f28dc5af6b72390c82f494f34cb9809546

This commit updates the EE repository reference after PR #608 was merged in windmill-ee-private.

Previous ee-repo-ref: 6c7da03fb994be23ed6aca59bece94d257a641b5

New ee-repo-ref: 481ea7f28dc5af6b72390c82f494f34cb9809546

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-06-08 14:46:47 +00:00
Ruben FiszelandClaude Opus 4.8 82cb7bf375 whitelabel default timeout + test-job callbacks (#9469)
Add a configurable `defaultTimeout` to the script/flow editor whitelabel
customUi (replaces the hardcoded 300s default) and an `onTestJob` callback
on ScriptBuilder/FlowBuilder that fires with the preview job id when a test
run starts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:43:07 +00:00
GuilhemandClaude Opus 4.7 64b089cd23 feat(frontend): use unified drill picker for AI chat @-mention dropdown (#9159)
* feat(frontend): use unified drill picker for AI chat @-mention dropdown

* fix(frontend): chat picker review followups + overlay alignment

- AIChatDisplay: migrate @-badge popover to ChatContextPicker (was still
  importing the deleted AvailableContextList after the rebase onto #9034,
  causing a build break).
- DrillPicker: handle Tab as Enter so the inline @<word> mention completes
  without losing focus. Tweak leaf-row weight to font-normal; secondary
  text uses text-hint.
- ContextTextarea: drop px-0.5 from the highlight span — extra horizontal
  padding made every glyph typed after a mention drift right of the
  invisible textarea below. box-decoration-clone keeps the rounded corners.
- ContextElementBadge: explicit font-normal label, hoist label into a
  {@const} and pass to title= so the truncated badge shows the full title
  on hover.
- workspaceTree: drop orphaned doc-comment left dangling by the rebase.
- Add unit tests for drillPicker.ts and workspaceTree.ts (51 tests cover
  resolveScope/scopeChain/collectLeavesGrouped/leafHaystack, buildWorkspaceTree
  shape + loading + dir forest + leaf shape, withCurrent rename suppression,
  extraItemsByKind dedup, legacyScopeToPath, relativizeWorkspacePath).

* fix(flow-editor): ignore keyboard shortcuts when focus is outside the flow root

Menus, modals, drawers etc. live outside the flow root and capture focus
explicitly. Flow nodes aren't focusable, so the unfocused default
(activeElement === body) means "flow is the canvas" and we should react;
anything else means another surface has the user's attention and our
shortcuts would steal it.

* fix(frontend): inline @ mention picker + chat layout polish

- ContextTextarea: swap manual Portal+caret-math positioning for
  svelte-floating-ui anchored at the `@` character (virtual reference,
  middleware [offset, flip(crossAxis:false), shift]). Picker stays
  pinned to `@` while the user types the query, slides leftward when
  hitting the right edge instead of flipping alignment, and floating-ui
  handles above-vs-below + edge clamping automatically. Drops the
  60vh-worst-case reservation that left a big gap above the caret in
  sessions, and the now-unused isFirstMessage prop is marked deprecated.
- AIChatDisplay: the `@`-button Popover now opens with placement
  bottom-start (was the default `bottom`), aligning its left edge with
  the button instead of centering under it.
- ChatContextPicker: when no Diffs/Modules/Databases branches are
  present (e.g. global chat), return the Workspace tree's children at
  the root instead of wrapping them under a redundant "Workspace" row.
  handleScopeChange handles both the wrapped and unwrapped layouts and
  the single-kind `dir:` top segment.

* chore(frontend): address review suggestions on chat picker PR

- DrillPicker: clamp width to viewport on narrow screens —
  w-[420px] → w-[min(420px,calc(100vw-20px))].
- workspaceTree.buildWorkspaceTree: make loadingKind optional (defaults
  to {}). Chat picker still passes it; callers that don't track loading
  no longer need to thread an empty object.
- ChatContextPicker.handleScopeChange: name the WRAPPED vs UNWRAPPED
  layouts in a comment block so the dir:/kind: branches are obvious.
- ContextTextarea: drop deprecated isFirstMessage prop (floating-ui
  handles direction); drop defensive Math.max on the @ index now that
  the invariant is documented; comment the floatingRef(anchorRef) call
  as the supported virtual-reference path in svelte-floating-ui.
- AIChatInput: stop forwarding isFirstMessage to ContextTextarea.

* feat(frontend): sync selectedContext with @-mentions in textarea

Both picker entry points now insert a visible `@title` token in the
textarea, and deleting that token drops the matching entry from
selectedContext.

- AIChatInput: new insertMention(title) export. Appends `@title ` to
  instructions, prefixing a space only if the existing text doesn't
  already end in whitespace.
- AIChatDisplay: the `@`-button popover calls insertMention after
  addContextToSelection so its picks match the inline-mention path's
  textarea state.
- ContextTextarea: new onRemoveContext callback. A $effect compares the
  set of `@title` tokens in `value` (derived) against the previous
  snapshot; titles that disappeared trigger onRemoveContext for any
  selectedContext entry with `deletable !== false`. The diff lives in
  an effect (not handleInput) so it catches both keystroke deletions
  AND programmatic value updates from updateInstructionsWithContext.
- AIChatInput: passes onRemoveContext that filters selectedContext by
  type+title — mirrors the existing badge X-button handler.

* chore(frontend): narrow ChatContextPicker `inner` from `any` to `DrillPicker | undefined`

The previous `let inner: any` worked around svelte-check rejecting
`DrillPicker<ChatLeafData>` (the imported component is seen as the
non-generic `Comp`). Dropping the type parameter keeps the workaround
without `any`, so handleKeydown / pickHighlighted are at least typed
at the call site.

Addresses May-14 PR review.

* fix(frontend): address PR #9159 bot-review findings (eager preload, focus, dedup, icon types)

- [P1] ChatContextPicker.handleScopeChange: stop preloading workspace
  kinds at the wrapped picker root. New `isWorkspaceOnly` $derived (true
  when no Diffs/Modules/Databases branches are present) gates the at-
  root preload, so the chat root no longer fires two list requests
  before the user enters Workspace. Reported by Codex.
- [P2] AIChatDisplay @-button popover: call aiChatInput.focusInput()
  after close() so the textarea is focused for immediate typing — mirrors
  the inline-mention path's setTimeout(textarea.focus, 0). Reported by
  Claude.
- [P2] AIChatInput.insertMention: no-op when the `@title` token is
  already present in instructions, so re-picking a workspace item
  doesn't leave duplicate visible tokens for a single selectedContext
  entry. Reported by Codex.
- [P2] drillPicker.ts: introduce `DrillIcon = ComponentType |
  Component<any, {}, ''>` and replace `icon: any` on DrillLeaf,
  DrillBranch, and ChatContextPicker.buildContextBranch. Mirrors the
  ComponentType | Component pattern used in TriggersBadge.svelte for
  the same Svelte 4/5 compatibility window. Reported by Pi.

* fix(frontend): preserve workspace context on refresh + load all kinds for internal search

- [P1, Codex] ContextManager.updateAvailableContextForScript/Flow:
  preserve workspace_script and workspace_flow entries through the
  selectedContext filter on editor refresh. They're user-picked refs
  that don't appear in availableContext, so the previous filter was
  silently dropping them whenever the script/flow editor refreshed
  options (e.g. on any code change).
- [P2, cubic-dev-ai] WorkspaceItemDrillPicker: in internal-search mode
  (externalFilter === undefined, DrillPicker renders its own search
  box), preload all kinds on mount. Without this, typing in the
  picker's search before clicking a kind branch produced incomplete
  results since DrillPicker can't reach back through the adapter to
  trigger fetches on internalFilter change. Cached items keep the
  effective cost near-zero on warm sessions.

* fix(frontend): preserve workspace refs through script-mode context refresh

The script-mode updateAvailableContext overwrites newSelectedContext
with a fresh [code] entry, defeating the workspace_script /
workspace_flow preservation in the later filter — the entries are
already gone by the time the filter runs.

Seed newSelectedContext with the refreshed code block AND the user-
picked workspace_script / workspace_flow / code_piece entries from
currentlySelectedContext, so editor refreshes don't wipe @-mention
badges in script chat. The existing line-271 filter still validates
each entry against newAvailableContext + the per-type allowlist.

Reported by Codex on PR #9159 — completes the prior workspace-context-
on-refresh fix (b02d1f2d35) which only patched the filter, not the
rebuild step that runs before it.

* fix(frontend): preserve all previously-selected contexts on script refresh

The prior c2775fe0c5 fix only carried over workspace_script /
workspace_flow / code_piece entries from currentlySelectedContext.
That preserved the workspace P1 path but still dropped previously-
selected diff / error / db / runtime-context badges, which cubic
flagged in its 16:55 review.

Spread the full currentlySelectedContext (minus `code`, which we just
rebuilt). The downstream filter validates each entry against
newAvailableContext + the per-type allowlist, so auto-derived types
like diff / error / db survive when still applicable, and unrelated
items are dropped automatically.

Reported by cubic-dev-ai on PR #9159.

* fix(frontend): rehydrate auto-derived context + sync badge X with textarea

- [P2, cubic] ContextManager.updateAvailableContext: when the rebuild
  carries over previously-selected diff/error/db entries, swap each one
  for the matching freshly-built entry from newAvailableContext in the
  final .map() step. Preserves the user's `deletable` override on top
  of the fresh content/diff/schema, so refreshes don't keep stale
  payloads while still surviving the badge across edits.
- [P2, Pi/Codex] AIChatInput: new `removeMention(title)` export that
  strips `@title` tokens from `instructions` (whitespace-bounded so
  substring matches don't bleed). The badge X-button now calls it
  after filtering selectedContext, mirroring the inverse textarea-to-
  badge sync. No double-remove: ContextTextarea's $effect-driven
  onRemoveContext is a no-op once selectedContext no longer holds the
  entry.

* fix(frontend): retype ChatContextPicker.inner to DrillPicker<ChatLeafData>

`npm run check:fast` (TypeScript-only) and `npm run check` (svelte-check)
disagree on whether the imported DrillPicker is generic — `check:fast`
sees it as `Comp` and rejects the type parameter, while `svelte-check`
sees the real generic component and requires it. CI runs `check`, so
follow that: `DrillPicker<ChatLeafData> | undefined`.

This also fully replaces the prior `inner: any` workaround called out in
multiple bot reviews — handleKeydown / pickHighlighted now type-check at
the call site against the correct component instance.

* fix(frontend): scope removeMention's whitespace collapse to the mention site

The trailing `.replace(/  +/g, ' ')` in `removeMention` was global,
collapsing any pre-existing double-spaces in the prompt — e.g. a user
typing `"hello  world  @foo  bar"` lost their intentional formatting
when they deleted the `@foo` badge.

Rework the regex to match `(^|\s)@title(\s|$)` and decide per-match:
- Mention at a boundary (no lead or no trail): drop entirely.
- Mention in the middle: keep ONE bordering whitespace char (the
  leading one verbatim, so newlines/tabs aren't downgraded to spaces).

No global pass over `instructions`. Unrelated whitespace stays intact.

Reported by cubic-dev-ai on PR #9159 (07:27 review of 9e07eac4).

* fix(frontend): expose DrillPicker.onFilterChange + lazy-load workspace kinds

Both Codex P1s came from over-eager preload heuristics on my prior fixes:
the workspace picker cold-loaded every configured kind on mount in
internal-filter mode, and the chat badge popover never observed its own
internal filter so workspace results were missing from search until the
user drilled into Workspace.

Replace both ad-hoc effects with a single `onFilterChange` callback on
DrillPicker that fires whenever the EFFECTIVE filter (external or
internal) changes:

- [P1] WorkspaceItemDrillPicker: drop the "cold-load on mount when
  externalFilter === undefined" effect. Workspace kinds now load only
  once the user actually types something — closer to the pre-refactor
  behavior where the breadcrumb / "Open editor" pickers only fetched
  the drilled-into kind plus all kinds on search.
- [P1] ChatContextPicker: handleFilterChange replaces the prior
  externalFilter-only effect. Badge-popover search (internal filter)
  now triggers the same preload as inline-mention search (external
  filter), so workspace results appear without needing to drill first.

Both fixes reported by Codex on PR #9159.

* fix(frontend): skip mention-removal sync when textarea is programmatically cleared

sendRequest() sets `instructions = ''` immediately after dispatching to
AIChatManager. The mention-removal effect treated this as user-initiated
deletion and cleared selectedContext BEFORE AIChatManager.beforeSend
snapshotted it — selected `@` contexts disappeared from the outgoing
request. Skip the sync when value is empty; user-initiated mention
deletes happen in-place against non-empty content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): scope post-send wipe protection to the send path only

Replace the blanket `if (value !== '')` guard on the mention-removal
effect with an explicit `clearForSend()` export. `sendRequest()` now
calls it instead of `instructions = ''`, so a user manually clearing
the whole textarea still drops the corresponding context badges while
the post-dispatch programmatic wipe is silent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): extract useWorkspaceItemsLoader composable shared by both drill picker adapters

WorkspaceItemDrillPicker and ChatContextPicker each duplicated the same
machinery: loaded/loadingKind state seeded from the module cache, a
stale-while-revalidate ensureLoaded coroutine with an untrack guard, a
kind:/dir: scope-segment decoder, and the "load every kind once the user
starts searching" filter callback.

Move that to a single useWorkspaceItemsLoader() returning
{loaded, loadingKind, ensureLoaded, ensureAll, ensureForScopeSegment,
onFilterChange}. Adapters keep their own scope-walking policy (chat
collapses an optional 'workspace' wrapper, workspace handles single-kind
mode) but delegate kind decoding and lazy fetch to the composable.

Net: -135 +28 LOC in the two adapters; +109 LOC in the new composable.
The cache-version race, untrack discipline, and stale-while-revalidate
semantics now live in one place.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): address Codex P1+P2s — non-context clear, same-title cross-removal, single-kind cold load

P1: sendRequest() now clears `instructions` unconditionally after the
optional `clearForSend()` so APP/NAVIGATOR/ASK/API modes (which don't
mount ContextTextarea) still reset the input after send.

P2: removeMention() now calls a new `unsyncMention(title)` on the
textarea before stripping `@title` from `value`, so the mention-removal
effect doesn't fire a second onRemoveContext on a same-title sibling
(e.g. workspace_script + workspace_flow sharing a path).

P2: single-kind WorkspaceItemDrillPicker loads its kind at mount even
when scope is empty — buildWorkspaceTree collapses to the kind's
children, so there's no kind row to drill into to trigger the load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:52:41 +00:00
Ruben FiszelandClaude Opus 4.8 6156e2372a fix: gate native integration pickers behind non-operator check (#9465)
Operators are read-only and cannot create native triggers, yet the
google/github/nextcloud integration picker routes had no authorization
gate, letting any workspace member drive the admin-configured
integration's upstream API and enumerate its data (Drive files, repo
names, calendars, events).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:18:14 +00:00
Amey Pawar 44f5dd6636 fix(frontend): respect forced column order for numeric column names (#9463) 2026-06-07 07:55:12 +00:00
Ruben Fiszelandrubenfiszel 004339032e chore(main): release 1.719.0 (#9459)
* chore(main): release 1.719.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-06 08:16:43 +00:00
Ruben FiszelandClaude Opus 4.8 fbdf81ba5f fix: authenticate slack callback payload with per-workspace hmac (#9461)
* fix: authenticate slack callback payload with per-workspace hmac

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: regression tests for unauthenticated slack callback decryption

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: verify slack submission signature before resume + close workspace oracle

Addresses review: verify private_metadata HMAC before handle_resume_action so a
tampered/unsigned submission is rejected up front, and map get_workspace_key
failure to the generic 401 so the status code is not a workspace-existence oracle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: domain-separate slack payload hmac from resume-secret hmac

Both MAC families key Hmac<Sha256> on the same per-workspace key; resume secrets
are distributed to approvers in resume URLs, so add a fixed domain tag
(slack_payload_v1) to the slack payload MAC to make the two non-interchangeable
by construction rather than by byte-layout coincidence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:02:20 +00:00
Amey Pawar 6a15a9b152 fix(python): escape reserved-keyword step ids in wrapper codegen (#9460)
A flow inline step whose id is a Python keyword (e.g. `in`) crashed with a
`SyntaxError`: the wrapper emits `from {pkg} import {step_id} as inner_script`,
and `from x import in as y` is invalid Python.

The codegen already prefixes `_` to path segments that start with a digit
(`1234` → `_1234`); this extends that guard to Python hard keywords (`in` →
`_in`) in `compute_python_module_dir` and on the leaf in `compute_py_codegen`
and `prepare_wrapper`. The relative-imports write path inherits it for free.

Fixes #8893
2026-06-06 07:56:01 +00:00
Ruben FiszelandClaude Opus 4.8 e1e7af6a25 fix: prevent token label collision bypassing job read access control (#9462)
* fix: prevent token label collision bypassing job read access control

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: regression tests for token label collision job read access

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: bind job-read override fast-path to permissioned_as_email

Replaces the reserved-label / label-* exclusion approach: webhook-/http-/email-
labels are created through the public token API by the trigger panels, so they
cannot be reserved, and blocking label-* regressed legitimate re-reads. Instead
the username_override fast-path now requires the job's permissioned_as_email
(non-forgeable, never derived from the label) to equal the caller's email.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:47:52 +00:00
fad1a549d9 feat(otel): connect jobs to the inbound distributed trace (#9456)
* feat(otel): propagate inbound W3C traceparent to job spans

Capture the inbound traceparent header at the run endpoints
(WebhookArgs::to_args_from_format) into a reserved _wm_traceparent arg key
(gated on OTEL_TRACING_ENABLED), riding the args jsonb like
_ENTRYPOINT_OVERRIDE. At pickup, create_span_with_name attaches a span link
from the job's worker span to the originating distributed trace, so a job
triggered by an instrumented service is connected to the caller's trace
while keeping its UUID-derived trace id (trace-by-job-id unaffected).

The link/parse logic lives in the EE otel modules; this OSS side only
captures the header and calls the (no-op outside EE) hook. Companion EE PR
required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump ee-repo-ref to inbound-trace-propagation EE branch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(agents): don't attribute work to specific customers in repo content

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(otel): relocate job + script spans into the inbound trace

Builds on the captured _wm_traceparent: the worker job span is re-parented on
the inbound caller context, the script subprocess's TRACEPARENT env is the
inbound context (so its spans join the caller's trace), and the context is
propagated to flow steps so the whole flow relocates. Carried to the worker via
a new LogContext.inbound_traceparent field. Non-inbound jobs are unchanged.

Adds a relocation integration test. Companion EE PR required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump ee-repo-ref to inbound-trace-propagation relocate commit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(otel): harden inbound traceparent capture

Address review feedback:
- strip any caller-supplied _wm_traceparent from args/extra before stashing the
  header-captured value, so the reserved key is Windmill-controlled only
- valid_w3c_traceparent: reject version ff and require lowercase hex, so we don't
  forward an inbound header that downstream OTel parsers would reject
- clarify that the capture helper does not validate the W3C format (done at use)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 2c7964460327fab5e3a27c0f74b8d6f26ab7f79a

This commit updates the EE repository reference after PR #604 was merged in windmill-ee-private.

Previous ee-repo-ref: 8fc04fb105dc49769205f7174d551a0d134d1bec

New ee-repo-ref: 2c7964460327fab5e3a27c0f74b8d6f26ab7f79a

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-06-05 16:50:07 +00:00
Ruben Fiszelandrubenfiszel 3887bf67dc chore(main): release 1.718.0 (#9450)
* chore(main): release 1.718.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-05 14:55:10 +00:00
90677872f6 fix: distinguish canceled jobs in runs (#9452)
* fix: distinguish canceled jobs in runs

* fix: order status=failure|canceled by completed_at to use partial index

The new `status` query param replaced the legacy `success=false` filter on
the Runs page, but the ORDER BY switch in list_completed_jobs_query only
flipped to v2_job_completed.completed_at for success==Some(false). With
status=failure|canceled (and success=None), the query fell back to ordering
by v2_job.created_at, which the partial index
ix_v2_job_completed_failure_workspace (workspace_id, completed_at DESC WHERE
status IN ('failure','canceled')) cannot serve.

EXPLAIN ANALYZE on 500k rows (1% failure/canceled): ordering by completed_at
uses the partial index (~150 buffers, 0.3ms); ordering by created_at scans
the v2_job created_at index and probes/discards 99% of rows via the join
(~49k buffers, 31ms). Switch the ordering to completed_at for
failure/canceled so the partial index serves both filtering and ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: trim order-by regression test to the failure/canceled case

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: only treat canceled as a terminal status icon for completed jobs

Guard the canceled branch in JobStatusIcon and getJobStatusKind with
`'success' in job` so a job that is still running while being canceled keeps
its running icon/favicon until it completes, instead of immediately showing
the gray Canceled state. Also clarify the openapi `status` param is an exact
match (status=success excludes skipped, unlike success=true).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:50:07 +00:00
Ruben FiszelandClaude Opus 4.8 7590b28108 feat(sandbox): pull/extract images with crane instead of podman (#9455)
* feat(sandbox): pull/extract images with crane instead of podman (+ add to image)

The sandboxed container runtime (`# sandbox <image>`) only ever pulls + flattens an
image (nsjail does the run), so a full container engine is overkill — and podman was
never actually in any Dockerfile, so the merged feature couldn't run in the shipped
image. Switch to crane (google/go-containerregistry): a single ~25MB static binary,
no daemon/store/root/privileged.

- docker_v2.rs: crane export -> flattened rootfs tar, crane config -> OCI config,
  crane digest -> content-addressed rootfs+config cache (cross-job dedup + automatic
  freshness), crane manifest -> pre-download size guard. DOCKER_CONFIG authfile dir.
  Cache eviction prunes the rootfs-tar cache by mtime (LRU). Pull policy honored via a
  ref->digest cache (missing/never reuse without a registry hit).
- Dockerfile + docker/DockerfileSlim{,Ee}: install the crane binary (Full/FullEe and
  the EE image inherit it via FROM the base image).
- docs + UI text + instance-setting descriptions updated (download size is compressed;
  cache is the rootfs-tar cache).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): address CI review — digest-pinned fetch, size cap on every job, eviction race

Codex P1s:
- Fetch by the resolved digest (name@digest), not the mutable tag, so content can't
  diverge from the digest the cache is keyed under if a tag moves mid-fetch.
- Enforce the size cap on EVERY job via a cached {digest}.size sidecar (no registry call
  on cache reuse), so lowering the limit rejects already-cached oversized images.
- Eviction race: hardlink the cache tar into the job dir before tar -xf (pins the inode
  against concurrent eviction) and re-fetch if it was evicted first.
Claude P2s: atomic config sidecar (tmp+rename) + tolerate torn parse; soften the LRU
comment (mtime = creation order); sweep orphaned *.tmp.* and .size on eviction.
+digest_key/ref_key unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): P1 cross-fs cache staging (EXDEV), Dockerfile arch fail-fast

CI re-review (Claude + Codex P1): the eviction-race hardlink crosses filesystems in the
shipped deployments — the cache is its own volume (/tmp/windmill/cache) while the job dir
is on the container fs — so hard_link returns EXDEV (not NotFound) and every sandbox job
fails. Fall back to tokio::fs::copy on a non-NotFound link error; copy reads through the
source inode so it still survives a concurrent eviction.
Also: Dockerfiles fail fast with a clear error on an unsupported arch instead of building
a 404 crane URL; ref->digest file written via tmp+rename (no torn read under missing/never).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sandbox): say 'oldest by creation time' not 'LRU' for cache eviction

Codex P2: the code evicts by tar creation time (cache hits don't touch mtime), so the
user-facing docs + instance-setting text shouldn't claim true LRU.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:52:55 +00:00
Ruben FiszelandClaude Opus 4.8 9a609bf08a feat: make C# dotnet target framework configurable via DOTNET_TARGET_FRAMEWORK (#9454)
* feat: make C# dotnet target framework configurable via DOTNET_TARGET_FRAMEWORK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: include dotnet target framework in C# binary cache key

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:43:40 +00:00
Ruben FiszelandClaude Opus 4.8 1727271e19 feat: sandboxed daemonless container runtime via '# sandbox <image>' (#9453)
* feat: add sandboxed docker v2 runtime via '# docker <image>'

Run a container image as a subprogram of the job's own nsjail sandbox:
extract the image rootfs with podman (rootless) and run it chrooted inside the
job's nsjail, so the container inherits the job's confinement and is safe under
nsjail / for untrusted code. Selected by '# docker <image>'; a bare '# docker'
keeps the v1 (dind) path untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: default to daemonless docker (drop dind from compose, allow docker on cloud)

docker-compose no longer ships the dind sidecar (v2 is daemonless: podman + nsjail
in the worker); removed the dind service, DOCKER_HOST env, depends_on and volume.
Removed the language-picker guard that blocked Docker scripts on the multi-tenant
platform, now that v2 makes docker safe to run sandboxed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: select sandboxed container via # sandbox <image>; add pull policy + size guards

- Surface moved from '# docker <image>' to '# sandbox <image>' (groups under the
  sandbox annotation; '# docker' stays v1-only, '# sandbox' stays nsjail-bash).
- SANDBOX_IMAGE_PULL_POLICY (default 'newer') so moving tags don't go stale.
- SANDBOX_IMAGE_MAX_SIZE_MB rejects oversized images before extraction.
- SANDBOX_IMAGE_CACHE_MAX_MB best-effort LRU eviction of podman's image store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sandbox): support # volume, honor nsjail tmp instance settings, v2 docker template

- Thread shared_mount into the sandbox container nsjail config so '# volume' mounts
  (and the same-worker /tmp/shared folder) apply inside the container.
- Use resolve_nsjail_tmp_mount_block for the container's /tmp so it honors the same
  nsjail_tmp_backing / nsjail_tmpfs_size_mb instance settings as other nsjail jobs.
- docker-compose comment + the editor's Docker template now use '# sandbox <image>'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sandbox): make image size/cache/pull-policy UI instance settings

Convert SANDBOX_IMAGE_* from worker env vars to DB-backed instance settings
(sandbox_image_max_size_mb, sandbox_image_cache_max_mb, sandbox_image_pull_policy),
hot-reloaded via the same mechanism as nsjail_tmpfs_size_mb and configurable in
#superadmin-settings. No worker restart needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sandbox): windmill-managed registry — default registry + private auth

Two new instance settings:
- sandbox_image_default_registry: prepended to unqualified image refs (alpine ->
  <registry>/alpine); fully-qualified refs untouched.
- sandbox_registry_auth: docker/podman auth.json blob written to a per-job authfile
  (0600, removed with the job) and passed to podman --authfile for private registries.
Both hot-reloaded and configurable in #superadmin-settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): protobuf-safe proto_str escaper, atomic 0600 authfile, registry tests

Addresses local-review P2s: proto_str now emits valid protobuf octal escapes for
control/non-ASCII bytes (not Rust \u{..} that nsjail would reject); the registry
authfile is created 0600 atomically (no world-readable window); add a
registry_qualified table test + a non-ASCII proto_str case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): P0 — deliver image env via nsjail envar:, never the launcher process env

CI review (P0): the image's OCI Env (attacker-controlled keys+values) was applied to
the nsjail launcher process via .envs(), so a hostile image could set LD_PRELOAD/
LD_LIBRARY_PATH/LD_AUDIT on nsjail itself and execute code as the worker outside the
jail. Now the image env is rendered as proto-escaped 'envar:' directives (child-only)
and nsjail's process env carries only windmill-trusted keys (reserved vars + proxy).
Also: warn instead of silently bypassing the size guard on inspect failure; reset the
eviction guard via a Drop guard (no stuck flag on panic/early-return). +render_envars test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): P0 symlink-write escape via rootfs script; P1 redact registry-auth logging

CI review:
- P0 (Codex): the body was written into the image-controlled rootfs as
  .windmill_docker_main.sh via write_file (follows symlinks) — a hostile image could
  plant that path as a symlink to a host file and capture the worker's write before
  nsjail starts. Now the body is passed straight to 'sh -c <body> sh <args>'; no file
  is written into the rootfs at all.
- P1 (Codex): sandbox_registry_auth flowed through the generic setting loader which
  logs the value (raw auth.json credentials). Replaced with a secret-aware reload that
  loads directly and logs only a redacted 'configured=' message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): redact sandbox_registry_auth in instance-settings write log too

The settings API also logs 'Set global setting <key> to <value>' via format_setting_value;
add sandbox_registry_auth to SENSITIVE_SETTINGS so the credential is redacted there as
well as on reload.

* fix(sandbox): don't silently disable cache eviction on podman images parse error

Re-review (cubic/Claude P2): serde_json::from_slice(...).unwrap_or_default() meant any
parse hiccup (e.g. podman omitting Size/Created via omitempty for a zero value, or
schema drift) silently degraded to an empty Vec and disabled eviction with no log.
Now Size/Created are #[serde(default)] (a missing omitempty key -> 0, not a whole-array
parse failure) and a real parse error warns + breaks instead of being swallowed.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:35:51 +00:00
fb175e1c9d fix ee repo ref dynamic oauth urls (#9451)
* ee repo ref

* fix(ee-ref): pin to EE commit that includes read_only create_session_token fix

The previous pin (f7a83d9) carried only the connect_config_template change and
dropped Ruben's read_only=false fix (EE 3742e06). CE #9371 made
create_session_token require 6 args, so the EE overlay fails check_ee_full with
an arity error without it. Bump the pin to 9be38de, which includes both fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to fb106b89cdf4088b004dac6062adb029f3923887

This commit updates the EE repository reference after PR #603 was merged in windmill-ee-private.

Previous ee-repo-ref: 9be38def879f702cd0b134d9e71bbb17fbb9cfa4

New ee-repo-ref: fb106b89cdf4088b004dac6062adb029f3923887

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-06-05 10:07:01 +02:00
Ruben Fiszel fee23a5185 threat_model v0 2026-06-05 01:00:21 +00:00
00a96b82f3 add databricks icon (#9445)
Adds DatabricksIcon.svelte (brand mark, #FF3621) and registers it under
`databricks` in the shared APP_TO_ICON_COMPONENT map, so both the app and
hub frontends pick it up for the new Databricks hub integration.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-06-04 19:02:51 +00:00
hugocasaandClaude Opus 4.8 dad2bd0b93 add adobe acrobat sign icon (#9447)
Adds AdobeAcrobatSignIcon.svelte and registers `adobe_acrobat_sign` in
APP_TO_ICON_COMPONENT, for the Adobe Acrobat Sign hub integration
(windmill-labs/windmill-integrations#143).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:01:57 +00:00
hugocasaandClaude Opus 4.8 93a74f229a oauth: add ServiceNow + make per-instance OAuth providers registry-driven (#9449)
* oauth: add ServiceNow provider; make per-instance OAuth registry-driven

ServiceNow's OAuth endpoints are per-instance
(https://<instance>.service-now.com/oauth_auth.do + /oauth_token.do), like
Snowflake's. Rather than add another bespoke special-case, generalize:
a registry entry may carry a `connect_config_template` (label/placeholder/
help_url + {instance}-templated auth_url/token_url + req_body_auth +
optional extra_params_key/strip_suffix). The instance-settings UI renders
one generic instance-name input for any such provider and substitutes
{instance} to build the per-client connect_config — a new per-instance
provider needs only a JSON entry, no frontend code.

- oauth_connect.json: servicenow + snowflake_oauth now carry a
  connect_config_template (snowflake keeps its account_identifier
  extra_params key for backward compatibility).
- windmill-oauth: add the ConnectConfigTemplate struct (frontend-only
  metadata; the backend's existing connect_config override resolves the
  concrete URLs generically — no other backend change).
- AuthSettings/InstanceSettings: replace the Snowflake + ServiceNow
  special-cases with one registry-driven path (instanceInputs map,
  setupTemplatedOauthUrls, loadInstanceInputs); per-instance providers are
  derived from the registry for the builtins list + dropdown.

Pairs with windmill-integrations#139 (ServiceNow hub integration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: point ee-repo-ref at servicenow-oauth EE branch (revert at merge)

Temporary CI pointer so check_ee_full / cargo_test build against the EE
slack-literal fix (windmill-ee-private#602). Revert to a pinned SHA once
that EE PR is merged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:58:47 +00:00
hugocasaandClaude Opus 4.8 eb55356018 add wiz icon (#9448)
Wiz star logomark (brand blue #0254EC) for the shared icon map
(APP_TO_ICON_COMPONENT), for windmill-integrations#144.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:58:29 +00:00
hugocasaandClaude Opus 4.8 f2f0812a04 feat(flows): opt-in to include the stopping step's result in early-stop errors (#9446)
* feat(flows): early stop can include the stopping step's result in the raised error

When a step uses Early Stop with "Raise an error message if stopped", the
flow result was entirely replaced with a static error object
({"error": {"name": "EarlyStopError", "message": "..."}}), discarding the
stopping step's own output. This made it impossible to stop+fail a flow
while preserving the data the step produced (e.g. an API that returns
HTTP 200 with a userErrors payload).

Add an opt-in `error_include_result` flag on StopAfterIf. When enabled on
the raise-error path, the raised payload becomes
{"error": {...}, "result": <step result>} instead of dropping the result.
Default is false, so existing behavior is unchanged. The option is threaded
through the worker's stop-after-if handling (including stop_after_all_iters_if
for loops/branchall) and exposed in the flow editor's Early Stop panel.

Fixes WIN-2012

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(flows): cover early-stop error_include_result payload shaping

Add a regression test asserting that a step using Early Stop with a raised
error message and error_include_result=true fails the flow while preserving
the step output as {"error": {..}, "result": <step result>}, and that with
the flag off the result is the bare {"error": {..}} object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(flows): nest early-stop step result inside the error object

Embed the stopping step's result under `error.result` rather than as a
top-level sibling of `error`. This keeps the flow result shape as
`{ "error": { .. } }` — identical to a normal error — so consumers that
key off the top-level shape (single `error` key) keep working, while the
data is still preserved for those that look inside the error object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(flows): always include the stopping step's result in early-stop errors

Drop the opt-in `error_include_result` gate. Since the step result is nested
inside the error object (`error.result`), the top-level result shape stays
`{ "error": .. }` — identical to a normal error — so consumers that detect or
parse failures by the top-level shape are unaffected. Gating it added schema
surface, plumbing, and a UI toggle for no real compatibility benefit.

Now, whenever a step early-stops with a raised error message, the flow fails
and the raised error embeds the stopping step's own result under
`error.result` (aggregated iteration results for loops/branchall). This
reverts the `StopAfterIf.error_include_result` field, its threading, the
OpenAPI/generated-client surface, and the editor toggle; the "Raise an error
message" tooltip now notes that the step result is included.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(flows): gate early-stop result inclusion behind opt-in flag

Re-introduce the per-step `error_include_result` flag (default off) instead
of always embedding the step result. Although nesting the result under
`error.result` keeps the result *shape* backward-compatible, it does not
address data exposure: a failed flow's result is propagated to synchronous
webhook callers, the flow's failure module, and the workspace/global error
handler (commonly a Slack/email/outbound-webhook notifier). Always including
the step output would surface previously-redacted intermediate data to all of
those sinks for every existing error-stop flow.

Gating keeps the existing behavior (bare `{ "error": .. }`) as the default and
only embeds `error.result` when the flow author explicitly opts in, matching
the original issue's intent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(flows): omit error_include_result when false; refresh generated prompts

- Add `skip_serializing_if = "is_false"` to `StopAfterIf.error_include_result`
  so serialized flows are byte-identical when the flag is off. Fixes the
  `flowmodule_serde` round-trip test (cargo_test) and avoids churn on existing
  flows.
- Regenerate `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`
  for the new OpenFlow `error_include_result` property. Fixes check-freshness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(flows): cover error_include_result for the loop "stop after all iters" path

Add a regression test for the stop_after_all_iters_if branch, where `nresult`
already holds the aggregated iteration results — confirming `error.result`
carries each iteration's output (distinct from the per-step fallback path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:57:05 +00:00
Ruben Fiszelandrubenfiszel 24fa61d3c0 chore(main): release 1.717.1 (#9444)
* chore(main): release 1.717.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-04 10:20:28 +00:00
Ruben FiszelandClaude Opus 4.8 f595787409 fix: invalidate relative-import cache when imported script changes (#9443)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:09:06 +00:00
Ruben Fiszelandrubenfiszel 6b6c16e6bc chore(main): release 1.717.0 (#9439)
* chore(main): release 1.717.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-04 07:37:15 +00:00
Ruben FiszelandClaude Opus 4.8 b5a6a1eeab fix(cli): push whole raw app instead of treating frontend files as scripts (#9442)
* fix(cli): push whole raw app instead of treating frontend files as scripts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): shorten raw-app handleFile comment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:30:56 +00:00
centdixandClaude Opus 4.8 819ba5e150 fix: read latest db draft for scripts/flows in global mode read tool (#9441)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:06:05 +00:00
hugocasaandClaude Opus 4.8 468aa230e5 refactor: resolve workspace imports via /f/,/u/ not $f/,$u/ aliases (#9438)
* refactor: resolve workspace imports via /f/,/u/ not $f/,$u/ aliases

Keep the CLI managed tsconfig.wmill.json / `refresh tsconfig` / Deno
import-map QoL from #9378, but re-key it on the existing /f/,/u/ workspace
paths instead of the new $f/,$u/ specifiers. Verified /f/,/u/ resolves in
tsc, Bun, Deno, the in-app ATA editor, and the worker, so the $-prefixed
alias added no value. Drop the $f/,$u/ handling from the parser, dep-map,
deno_executor, bun loaders, ATA, relative_imports and monaco paths; revert
the windmill-parser-wasm-ts bump (1.714.0 -> 1.695.0). Also fold in the
cli/package-lock.json sync for the already-committed pg-gateway dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: drop duplicate relative-path check and restore rustfmt formatting

Follow-up cleanups to the previous commit's full-file reverts, which
restored pre-#9378 state that main had since improved:

- relative_imports.ts: remove the redundant duplicate d.startsWith('/')
  (pre-#9378 had it; #9378 had repurposed that line, so main has no dup).
- windmill-parser-ts/src/lib.rs: restore the multi-line new_source_file(...)
  formatting required by backend/rustfmt.toml (the single-line revert would
  fail `cargo fmt --check`). Now differs from main only by the $f//$u/ removal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:27:24 +00:00
GuilhemandClaude Opus 4.8 e4e0984e55 feat: let flow AI chat create and edit sticky notes (#9412)
* feat: let flow AI chat create and edit sticky notes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: strengthen flow AI guidance to prefer groups for organizing flows

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: harden flow note validation (validate position/size, document color default and group acceptance)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: make AI-created free notes draggable by seeding default position and size

Free notes need explicit geometry to be draggable/resizable in the editor; UI-created notes always set position+size but agent-created notes omitted both, so they couldn't be moved until resized. Seed defaults in validateFlowNotes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:27:01 +00:00
Ruben Fiszelandrubenfiszel d537c82c4f chore(main): release 1.716.0 (#9430)
* chore(main): release 1.716.0

* Apply automatic changes

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-03 14:01:02 +00:00
Ruben FiszelandClaude Opus 4.8 e3acb7bbd9 break main.ts <-> utils.ts circular import causing TDZ crash (#9436)
`cli/src/utils/utils.ts` imported `VERSION` from `cli/src/main.ts`, while
`main.ts` transitively imports `utils.ts` (via `workspace.ts`). When a module
load order entered the graph through `workspace.ts -> utils.ts -> main.ts`,
`main.ts`'s top-level command tree ran while `workspace.ts` was still
mid-initialization, so the `workspace` binding was still in its temporal dead
zone at `.command("workspace", workspace)`:

    ReferenceError: Cannot access 'workspace' before initialization

This surfaced as 56 failing CLI tests on Windows CI (the Windows runner's test
module-load order triggers the bad path; it reproduces on any platform via
`bun -e 'await import("./src/commands/workspace/workspace.ts")'`).

Move `VERSION` to `cli/src/core/constants.ts` (already the "minimal imports"
module), re-export it from `main.ts` for backwards compatibility, and have
`utils.ts` read it from `constants.ts` — eliminating the cycle. Release tooling
(`.github/change-versions*.sh`) is updated to rewrite the `VERSION` line in its
new location.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:48:26 +00:00
Ruben FiszelandClaude Opus 4.8 073857ac0a fix(apps): relock no longer reverts raw app to a stale version (#9432)
When a dependency job for an app is triggered by a relative/workspace
import (e.g. an imported script was updated), handle_app_dependency_job
re-appended the version captured at job-creation time to the versions
array. On a git-sync/CLI push that deploys both the imported script and
the importing app in the same batch, the script's dependency job
snapshots the app's old version; the app push then creates a newer
version (uploading its bundle against that new version); finally the
relock runs and re-appends the old version, making it latest again.

For raw apps this is fatal: bundle_secret is computed from the latest
version, so the served HTML requests /apps_u/get_data/v/<secret>.{js,css}
for a version that has no stored bundle -> 404 and a white screen.
Manually redeploying fixes it until the next merge re-triggers the revert.

Two changes:
- Re-query the current latest version to relock (mirrors the flow
  dependency handler, #8673), so we don't lock a stale snapshot.
- Guard the re-publish append with `versions[array_upper(...)] = $1` so
  it is a single atomic, never-demoting statement: it can only re-append
  the version that is already latest, never revert to an older one. A
  relock never creates a new app_version, so there is never a version to
  legitimately promote here.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:21:54 +00:00
Guilhem 732dc0f617 apply COEP headers to dev static files for raw app editor (#9433) 2026-06-03 13:21:09 +00:00
centdix 79178f6f5a feat: use metadata model for small AI tasks (#9431) 2026-06-03 12:13:14 +00:00
hugocasaandClaude Opus 4.8 220cd35cf7 feat: support $f/ and $u/ import path aliases for scripts (#9378)
* feat: support $f/ and $u/ import path aliases for scripts

$f/ and $u/ are local-friendly aliases for the absolute workspace
import paths /f/ and /u/. Unlike the /-prefixed form (which local tools
treat as a filesystem-root path), the $-prefixed form is a bare specifier
that can be remapped via tsconfig paths / Deno import maps, so the same
import resolves on the Windmill worker and in a local editor.

- worker: recognize $f//$u/ in the Deno import map and both Bun loaders
- dep-map/parser: normalize $f/->f/, $u/->u/ for lockgen + dep tracking
- cli: emit $f/$u path aliases in generated tsconfig.json / deno.json
- frontend: ATA + Monaco paths resolve $f//$u/ type hints in the editor

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): split generated tsconfig into managed + user file with refresh command

Mirror the AGENTS.cli.md/AGENTS.md prompts model for the IDE tsconfig so the
recommended settings can evolve without ever clobbering user customizations:

- tsconfig.wmill.json: wmill-managed, always refreshed, holds recommended
  compilerOptions incl. the $f/$u path aliases (Deno: import_map.wmill.json)
- tsconfig.json: user-owned, created once, just extends the managed file;
  warn (never auto-edit) when an existing one doesn't reference it
- add 'wmill refresh tsconfig'; init generates it unconditionally (no longer
  gated behind resource-type namespace / a bound workspace)
- regenerate CLI guidance docs for the new subcommand

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): address PR review on $f/ tsconfig generation

- handle existing deno.jsonc so we don't shadow it with a new deno.json
  (P1 identified by cubic)
- fix the bun-types hint that pointed users at the managed do-not-edit
  tsconfig.wmill.json; tell them to install + re-run 'wmill refresh tsconfig'
- document the .ts-extension-only local-resolution limitation (cross-flavor
  .bun.ts/.deno.ts/.fetch.ts scripts won't resolve in a local editor)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): warn when a project's tsconfig isn't wired to tsconfig.wmill.json

Mirror the prompts freshness check for the managed tsconfig so users with an
existing setup actually discover they're missing $f//$u/ resolution:

- embed a version hash in tsconfig.wmill.json (excludes the env-dependent
  bun-types 'types' entry so it doesn't false-positive)
- add warnIfTsconfigStale to the main.ts freshness hook, gated identically to
  the prompts check (skips init/refresh/help/version). When a tsconfig.json
  exists it warns one line (stderr) if the managed file is missing, not
  referenced via extends, or out of date; silent for non-TS projects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): make tsconfig setup equivalent to prompts (auto-wire + stale-only)

Unify the two managed-file systems so they behave identically:

- auto-wire an existing unlinked tsconfig.json/deno.json on init/refresh
  (add extends / importMap; merge into an array extends), instead of only
  warning. Parses JSON and falls back to a warning when it can't round-trip
  (JSONC comments, or a conflicting deno imports/importMap) — never corrupts.
- narrow warnIfTsconfigStale to stale-only, gated on the managed file
  existing, exactly like warnIfPromptsStale: it no longer nags about a
  missing or unlinked tsconfig.json, so a deliberately-custom/unlinked setup
  stays silent and a not-yet-initialized project isn't bothered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): place tsconfig.wmill.json first in extends to preserve user base config

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): migrate legacy tsconfig and require consent for custom configs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): align prompts wiring to the same consent model

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(cli): bump windmill-parser-wasm-ts to 1.714.0 for $f/ $u/ aliases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(worker): resolve $f/ and $u/ in deno lock generation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: narrow relative-imports lock-gen guard to deno import-map failure

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(cli): sync bun.lock with windmill-parser-wasm-ts 1.714.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): warn when a custom tsconfig's paths would shadow $f/ $u/ aliases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:58:12 +00:00
Ruben Fiszel b3027c35cb nit version priting 2026-06-03 11:22:50 +00:00
centdix 26b7270418 feat: auto-generate AI session names (#9399) 2026-06-03 10:35:03 +00:00
centdixandClaude Opus 4.8 343368fb5e test: add datatable tool coverage to global AI evals (#9398)
* test: add datatable tool coverage to global ai_evals (stage 0+1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: add seeded datatable difficulty-ladder global ai_evals (stage 2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: skipJudge datatable evals and make stringIncludesAnyOf existential

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: make ai_evals datatable mock reflect SQL writes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:34:37 +00:00
GuilhemandClaude Opus 4.8 c3d4c6474b gate session fork creation on CE workspace cap (#9411)
* feat: gate workspace fork creation in sessions behind enterprise license

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: gate session fork creation on CE workspace cap, not EE license

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:34:16 +00:00
centdix cf5fefb521 feat: add metadata generation model setting (#9418) 2026-06-03 10:33:16 +00:00
Ruben FiszelandClaude Opus 4.8 0ba128afe7 fix(security): scope variable and resource value caches by caller identity (#9427)
The variable and resource value caches (backing
`GET /api/w/{w}/variables/get_value/{path}?allow_cache=true` and
`.../resources/get_value_interpolated/{path}?allow_cache=true`) are consulted
before the per-folder RLS query and store the already-decrypted value. The
resource cache was keyed only by `workspace:path` with no caller identity, so a
cache entry warmed by a privileged peer using `allow_cache=true` could be
returned to a caller with no access to the resource's folder on a cache hit
within the 30s TTL — leaking another folder's decrypted secrets.

Scope both caches to the caller's full authorization identity. The key is now
`auth_identity(authed):workspace:path`, where `auth_identity` is a SHA-256 of the
caller's effective authorization context (email, username, is_admin, is_operator,
sorted groups, sorted folders, sorted scopes) — mirroring
`job_read_access_cache_key`. Email alone is insufficient: the same email can
resolve to different effective permissions via job/owner-scoped tokens, so a
lower-privilege context must not reuse a higher-privilege context's entry.

Job-context resource interpolation is handled correctly: only `$WM_*` contextual
variables are resolved (and only when a `job_id` is present). The interpolation
reports whether the value contains a `$WM_*` placeholder
(`transform_json_value_tracked` + an `AtomicBool`). A value containing one is
job-dependent — even on a no-job read where it's left unresolved — and is never
cached (so a later job read never gets a stale placeholder or another job's
context). Any value without a `$WM_*` placeholder is job-independent and cached
under the identity key, shared across job contexts, so reads carrying a `job_id`
still hit the cache.

BEHAVIOR CHANGE: custom workspace environment variables are no longer interpolated
into resource values via `$NAME` (this was undocumented and prevented caching of
any `$`-prefixed value). Custom envs remain available to scripts/workers as before.
Built-in `$WM_*` contextual variables in resource values are unchanged.

The variable cache previously wrote with an identity-scoped key but read with the
unscoped key, so it never hit (a latent functional bug that happened to be safe).
Aligning the read path enables the cache and makes it identity-scoped by
construction. Secret variables are cached too, but the entry carries the
`is_secret` flag so a cache hit re-runs the per-read side effects a secret read
performs — the EE `variables.decrypt_secret` audit and running-job secret
registration (factored into `audit_decrypt_secret`, shared by both paths).

The unused `invalidate_{variable,resource}_cache` helpers can no longer target
identity-scoped entries; documented the constraint and refreshed the stale
key-format docs on the cache statics.

Tests:
- integration regression for both caches: a folder-scoped user warms the cache via
  allow_cache=true, then a user without folder access is denied (401) and never
  receives the cached value.
- integration regression that variables (secret included) are served from cache.
- integration regression for job context: plain and non-`$WM_` `$`-string resources
  stay cached and are served under a job_id, while a `$WM_*` resource (warmed without
  a job_id) is not cached.
- unit tests for `auth_identity`.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:31:45 +00:00
Ruben Fiszelandrubenfiszel 47c96204de chore(main): release 1.715.0 (#9421)
* chore(main): release 1.715.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-03 10:08:16 +00:00
centdixandClaude Opus 4.8 11d1ad9a87 fix: omit temperature for gpt-5+ and o-series models on all providers (#9422)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:29:21 +00:00
Ruben FiszelandClaude Opus 4.8 8053266f88 fix(mcp): resolve MCP resource token via caller RLS + SSRF-guard url (#9428)
* fix(mcp): resolve MCP resource token via caller RLS + SSRF-guard url

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): clone user_db for oauth2 refresh and drop advisory ids from comments

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): disable redirects on MCP client to prevent SSRF bypass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:00:43 +00:00
Ruben FiszelandClaude Opus 4.8 7031744a19 fix(nsjail): precompile python stdlib + raise download rlimit_as (#9429)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:47:19 +00:00
Ruben FiszelandClaude Opus 4.8 3b2e748daf feat(frontend): add rebuild dependency map button to workspace settings (#9424)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:42:39 +00:00
Ruben FiszelandClaude Opus 4.8 7edf3f0212 fix(auth): filter script/flow listings by token scope (GHSA-2ppx-66jv-wpw5) (#9426)
A token scoped to a single script or flow path (e.g.
`scripts:read:f/allowed/*`) could call `GET .../scripts/list_search` (or
`/list`) and receive `path` + full `content` for every script the
underlying user could see — likewise `flows/list_search` leaked the full
flow `value`. Route-level scope checks only validate `domain:action`, and
the listing handlers did no per-row scope filtering, leaking out-of-scope
source/definitions to narrowly-scoped tokens.

Apply `build_scope_path_predicate` (added in #9302 for resources/variables)
to `list_search_scripts`, `list_scripts`, `list_search_flows`, and
`list_flows`, mirroring the resources/variables fix exactly. Unscoped
tokens and tokens whose only scopes are `if_jobs:filter_tags:*` are
unaffected.

Adds integration regression tests (scripts + flows) covering: path-scoped
token sees only in-scope paths, broad `*:read` token still sees all
RLS-visible items, tag-filter-only and unscoped tokens unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:34:43 +00:00
Ruben FiszelandClaude Opus 4.8 89a7a37776 fix(backend): authorize single-job read endpoints by job/flow visibility (#9416)
* fix(backend): authorize single-job read endpoints by job/flow visibility

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(jobs): share read links + cached access checks for run visibility

- Cache the job read-access RLS probe (size-bounded LRU keyed by the caller's
  authz-relevant identity + job id; no TTL since job-side inputs are immutable).
- Inherit visibility along the full parent_job chain so any flow you can see lets
  you read its (deeply nested) steps.
- Share read links: GET /jobs/job_view_token/{id} mints a stateless
  HMAC(workspace_key, job_id) token (only if the caller can read the job); the
  token grants an authenticated member read of that job and its flow subtree via a
  ?view_token query param or X-View-Token header. Run page gains a Share button and
  honors a ?view_token link.
- Denied-but-existing reads now return 403 with guidance to request a share link
  (vs 404 for non-existent), and the run page renders that case with instructions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(jobs): address PR review — scope-tag check on mint, constant-time view-token verify

- P1 (Codex): get_job_view_token now enforces the caller's if_jobs:filter_tags
  scope before minting, so a tag-scoped token can't mint a transferable link for a
  job outside its tags. Adds a scoped-token regression test (allowed + denied).
- Constant-time view-token verification (HmacSha256::verify_slice) instead of
  comparing hex strings (Claude/Pi nit).
- get_completed_job_result: an authed reader passing an invalid suspended-secret
  triple now falls through to the normal visibility gate instead of erroring out
  (Claude nit); unauthenticated callers still rejected.
- Length-prefix the read-access cache key fields so no input values can collide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(api): add job_view_token to openapi spec; use generated client in run page

Addresses Codex review nit: the new GET /jobs/job_view_token/{id} endpoint was
missing from openapi.yaml (the source the frontend client is generated from). Adds
the path + operationId getJobViewToken, and switches the run page's Share button
from a raw fetch to JobService.getJobViewToken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): carry view_token on share-link downloads

Addresses Codex review: download actions bypass the request interceptor that adds
X-View-Token (downloadViaClient uses raw fetch; cookie-mode downloads use plain
hrefs), so a share-link viewer got 403 downloading logs/results/args. Append the
view_token query param to the job download paths (result/logs/args/flow-all-logs)
via a new appendViewToken() helper, covering both client-fetch and href modes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(jobs): enforce tag scope in require_job_read_access (view-token use side)

Addresses Codex P1: the view_token use-side bypassed if_jobs:filter_tags on
handlers that don't tag-filter their data query (result_by_id,
get_flow_job_debug_info, get_otel_traces) — a tag-scoped token could use someone
else's valid share token to read out-of-scope job data. Move the tag-scope check
into require_job_read_access (runs before any created_by/view_token/RLS grant), so
it applies uniformly to every gated handler; removes the now-redundant explicit
check in get_job_view_token. Adds a use-side regression test (scoped token + valid
out-of-scope view_token denied on otel/result_by_id; in-scope still allowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): include workspace in share read link

Addresses Codex P1: the copied share URL omitted the workspace. The token is
signed with the run's workspace key and the logged layout only switches
$workspaceStore when the URL carries workspace=, so a recipient whose persisted
active workspace differs would open the link against the wrong workspace and the
token would fail validation. Pin workspace= alongside view_token in the link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(jobs): authorize get_result_maybe get_started branch for queued jobs

Addresses Codex P1: get_completed_job_result_maybe only gated when a completed row
existed; with ?get_started=true a non-reader reached the fallback branch and got
started:true for a running private job. Now fetches created_by and authorizes
(created_by/view_token/RLS, or anonymous for unauth) before disclosing
running-state; a non-existent job still returns started:false (leaks nothing).
Adds a regression test with a queued (no completed row) private job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:10:16 +00:00
Ruben FiszelandClaude Opus 4.8 fefa8e438d chore(docker): use nsjail runtime packages instead of dev packages (#9419)
Swap the final-stage nsjail dependencies (libprotobuf-dev,
libnl-route-3-dev) for their runtime-only counterparts (libprotobuf32,
libnl-route-3-200, libnl-3-200) across the root Dockerfile, DockerfileSlim,
and DockerfileSlimEe. nsjail is already compiled in the build stage, so the
final image only needs the runtime shared libraries. This shrinks the
images and reduces CVE scan noise from unused dev packages.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:36:47 +00:00
Ruben Fiszelandrubenfiszel 00cd89fff3 chore(main): release 1.714.1 (#9408)
* chore(main): release 1.714.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-02 12:58:41 +00:00
GuilhemandClaude Opus 4.8 2bff250f89 feat(frontend): harmonize diff button placement in script and raw app editors (#9410)
* feat(frontend): harmonize diff button placement across editors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): address review nits — drop unused diffDrawer param, fix stale comments

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:58:21 +00:00
Ruben FiszelandClaude Opus 4.8 ab2a15b2a8 fix(triggers): prevent Zoom challenge handler from being used as a signing oracle (#9413)
The Zoom URL-validation challenge handler in `handle_challenge_request`
would HMAC-sign any arbitrary `plainToken` and return the result. Since
Zoom webhook verification checks `HMAC-SHA256(secret, "v0:{ts}:{body}")`,
an attacker could craft a `plainToken` in that format to obtain a valid
signature for a forged body, bypassing authentication on a later request.

Unlike the Twitch handler, the Zoom handler verifies no signature on the
challenge request (Zoom's protocol does not include one). Reject any
`plainToken` containing `:` or longer than 128 chars: legitimate Zoom
validation tokens are short random hex strings that never contain colons,
while the exploit requires the colon-bearing `v0:{ts}:{body}` format.

Fixes WIN-2008

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:47:45 +00:00
Ruben FiszelandClaude Opus 4.8 9e6559a6f6 fix(nsjail): raise python download fd limit for --compile-bytecode (WIN-2009) (#9414)
#9393 added `--compile-bytecode` to the uv pip install run inside the
python download nsjail. uv spawns a Python interpreter that compiles .py
files with parallelism scaling to the host CPU count, opening many file
descriptors at once. The download nsjail capped `rlimit_nofile` at 64,
which is exhausted on high-core machines, failing every install with
"Failed to bytecode-compile ... Too many open files (os error 24)".

Low-core VMs never hit the cap, so this surfaced only as a regression on
larger workers after upgrading.

Raise `rlimit_nofile` to 10000, matching the runtime configs
(run.python3 / run.ansible) that already use that value.

Fixes WIN-2009

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:44:30 +00:00
Ruben FiszelandClaude Opus 4.8 73edebc833 fix(backend): route //native TypeScript previews to native workers (WIN-2007) (#9407)
* fix(backend): route //native TypeScript previews to native workers

Previewing a TypeScript script carrying the `//native` annotation was
pushed with `language = bun` (what the editor sends), so the job was
tagged `bun` and routed to a regular bun worker. A native-mode worker
neither matches the `bun` tag nor accepts a non-native `script_lang`
(worker.rs rejects with "cannot execute non-native job with language
'bun'"), so previewing a `//native` script on a native-only worker setup
failed — even though the deployed version of the same script runs fine
as `bunnative` / tag `nativets`.

`push` now reconciles the preview language with the `//native`
annotation for `JobPayload::Code`, mirroring the deploy-time logic in
`worker_lockfiles`: `bun` + `//native` is promoted to `bunnative` (tag
`nativets`), and `bunnative` without `//native` is demoted back to
`bun`. This makes a preview run exactly like the deployed script would,
and covers every preview entry point (run_preview_script, inline
preview, codebase preview) since they all go through `JobPayload::Code`.

Adds regression tests asserting the queued job's `script_lang`/`tag` for
all four (declared language × annotation) combinations.

Fixes WIN-2007

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(backend): add sqlx cache for preview_native_tag test query

The regression test's `sqlx::query!` for `v2_job` (tag, script_lang) needs
a cached entry so `SQLX_OFFLINE=true` CI compiles it. Adds exactly one new
cache file; no existing (OSS or EE) caches removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backend): trim preview native-tag tests to the essentials

Keep the core regression (bun + //native → bunnative/nativets) and the
guard that plain bun previews are unaffected. Drop the two bunnative-
declared cases, which only re-verified the mirrored demote logic and
weren't the reported issue. The shared query is unchanged, so the sqlx
cache stays valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:23:39 +00:00
Ruben Fiszelandrubenfiszel 2ac198396e chore(main): release 1.714.0 (#9390)
* chore(main): release 1.714.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-02 08:22:19 +00:00
8ad699d27b Refresh slim image runtime packages (#9396)
* Refresh slim runtime packages

* fix(docker): bump pre-baked python to 3.12.12, drop redundant pip/setuptools upgrade

Align the slim images' pre-baked uv-managed Python with the backend default
(PyVAlias::Py312), which previously requested 3.12 while the image baked 3.11.10
— a minor mismatch that made the pre-bake unusable (every default job re-downloaded
3.12 at runtime).

Pinning 3.12.12 (latest 3.12 in uv's list) also drops bundled setuptools entirely
and ships current pip via python-build-standalone, so the explicit
`uv pip install --upgrade pip setuptools` step is now redundant and removed.

Also remove the dead PYTHON_IMAGE ARG from RHEL8/RHEL9 Dockerfiles (declared but
never referenced in any FROM stage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:16:11 +00:00
Ruben FiszelandClaude Opus 4.8 24e3ef27be fix(cli): stop git-sync promotion deploys from dropping triggers/schedules (#9403)
* fix(cli): stop git-sync promotion deploys from dropping triggers/schedules

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump git-sync hub script to hub/28261 (windmill-cli 1.713.2)

Points LATEST_GIT_SYNC_SCRIPT_PATH at the republished sync-script-to-git-repo
that pins windmill-cli@1.713.2, which carries the promotion include-derivation
fix in this PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 07:44:04 +00:00
GuilhemandClaude Opus 4.8 30057445f9 avoid crypto.randomUUID in WorkspaceItemDrillPicker (#9405)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:16:55 +00:00
Ruben FiszelandClaude Opus 4.8 e356bb1f5d fix(cli): make encryption key push non-interactive-safe + add --skip-reencrypt-on-key-change (#9402)
When encryption_key.yaml changes and is pushed via `wmill sync push`,
pushWorkspaceKey prompted interactively to confirm re-encrypting the
remote secrets with the new key. That prompt ignored `--yes` and had no
TTY guard, so a CI/non-interactive push that included the key would
block (or behave undefinedly) on the prompt.

Thread a key-push options object (non-interactive flag + explicit
re-encryption choice) through pushObj into pushWorkspaceKey:

- Non-interactive (`--yes` or no TTY) and no explicit choice: skip the
  prompt and default to re-encrypting all remote secrets with the new
  key (matches the interactive default), preserving their plaintext
  values.
- New `--skip-reencrypt-on-key-change` flag (and the
  WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true env var for CI) opt out of
  re-encryption — only safe when the remote ciphertexts are already
  encrypted with the new key (e.g. workspace/instance migration).
- Interactive behavior (TTY, no `--yes`) is unchanged.

Regenerates system_prompts for the new option and adds unit tests for
the no-op, re-encrypt-by-default, flag-skip, and env-skip paths.

Fixes WIN-2005

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:11:29 +00:00
Ruben FiszelandClaude Opus 4.8 d71d553ba4 Windows build broken by #[cfg] on tokio::select! branch (#9404)
#9400 (WIN-2003) added the ctrl_break() handler as a `#[cfg(windows)]`
branch inside the two Windows-path `tokio::select!` blocks in
shutdown_signal. tokio's `select!` macro does not accept `#[cfg(...)]`
attributes on individual branches, so windmill-common fails to compile
on Windows ("no rules expected this token in macro call").

This slipped through CI because the only job that builds the backend on
Windows is cli-tests.yml's `test-windows`, which triggers only on
`cli/**` changes — #9400 was backend-only.

Fix: define `ctrl_break()` for the whole `not(any(linux, macos))` scope
instead of just `windows`. On Windows it awaits the real CTRL_BREAK
signal; on other non-unix targets it is a never-resolving future, so the
branch is inert there. The select! branches become plain (no per-branch
`#[cfg]`), which the macro accepts.

Verified: the `#[cfg]`-on-branch form reproduces the exact macro error
against tokio 1.46.1, and the fixed form compiles clean.

Fixes WIN-2003 (Windows build regression)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:10:53 +00:00
Ruben FiszelandClaude Opus 4.8 e8ad53dae9 fix: resolve username rename failing on apps with runnable deps (#9401)
The instance username-conflict resolver rewrote
workspace_runnable_dependencies.app_path to the new user path before the
app row itself was renamed, violating fk_workspace_runnable_dependencies_app_path.
That FK is ON UPDATE CASCADE, so renaming the app already propagates the new
path; the manual rewrite was redundant and mis-ordered. Any user owning an app
under u/<username>/ with a tracked runnable dependency hit HTTP 500 and could
not have their username conflict resolved.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 06:43:03 +00:00
Ruben FiszelandClaude Opus 4.8 2e1445616a feat: handle CTRL_BREAK_EVENT for graceful shutdown on Windows (#9400)
On Windows, shutdown_signal only registered ctrl_c() (CTRL_C_EVENT).
CTRL_BREAK_EVENT — the default kill signal sent by Nomad's raw_exec
driver on Windows — had no handler, so the worker terminated
immediately without graceful shutdown, interrupting running jobs.

Add a ctrl_break() helper (mirroring the Unix terminate() helper) and
register it as an additional branch in both Windows tokio::select!
blocks in shutdown_signal.

Fixes WIN-2003

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:37:35 +00:00
GuilhemandClaude Opus 4.7 de76668c10 fix(frontend): align Monaco editor font size with text-xs (#9161)
* fix(frontend): align Monaco editor font size with text-xs across viewports

* fix(frontend): make placeholder lineHeight reactive to fontSize

* fix(frontend): align GraphQL schema viewer font size with text-xs

The read-only GraphQL schema viewer was the lone Monaco instance still
inheriting Monaco's 14px default. Wire it through editorFontSize like
the other editors so it stays in sync with text-xs across viewports.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:29:19 +00:00
hugocasaandClaude Opus 4.8 ba0e4c8280 oauth: add salesforce provider (#9380)
* oauth: add salesforce provider

Register Salesforce OAuth (Authorization Code) for Windmill resource connect.
Production uses login.salesforce.com; the sandbox block points at
test.salesforce.com (URL overrides only; scopes inherited) per #9358, so a single
canonical `salesforce` resource type covers both with separate `salesforce_sandbox`
instance credentials.

Paired with the hub integration: windmill-labs/windmill-integrations#131.
The Salesforce icon already exists in the frontend (SalesforceIcon.svelte).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix JSON syntax error in oauth_connect.json

* fix: add salesforce production tile to OAuth settings dropdown

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:49:33 +00:00
GuilhemandClaude Opus 4.8 1275487f02 feat: refine ask-user-question chat display and keyboard nav (#9392)
* feat: refine ask-user-question chat display and keyboard nav

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: use text-accent for ask-user-question icon

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: focus active choice when clicking ask-user-question card

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: disable chat input while an ask-user-question is pending

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: focus active choice on card click instead of pointerdown

Preserves text selection on the question card; wired as a use: action so the non-interactive card needs no keyboard handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: extract isActiveUserQuestion shared predicate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cover isActiveUserQuestion predicate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:49:12 +00:00
centdixandClaude Opus 4.8 943ef6eb20 feat: add workspace datatable tools to global AI chat mode (#9395)
* feat: add workspace datatable tools to global AI chat mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cover global-mode datatable tools pure logic

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: expose datatable SQL SDK reference via get_instructions in global mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: make datatable get_instructions language-aware, default TypeScript

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop datatable/whitelist args from global init_app tool

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: flag missing datatable config as an explicit blocking error in global mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop dead branch in exec_datatable_sql result handling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:48:50 +00:00
Ruben FiszelandClaude Opus 4.8 c19441bc8c perf(python): add --compile-bytecode to uv pip install (#9393)
Python jobs under nsjail experience slow imports (~465ms for `import
requests`) because dependency directories are mounted read-only. Without
pre-compiled `.pyc` files, Python recompiles `.py` source to bytecode
in-memory on every import in every fresh nsjail process, paying the cost
repeatedly.

Add `--compile-bytecode` to both `uv pip install` invocations (the
Rust-driven install in python_executor.rs and the nsjail
download_deps.py.sh script) so `.pyc` files are generated at install
time and available at runtime even through read-only mounts. The files
are included in both the local cache and S3 piptar uploads.

The flag is supported by the uv version (0.9.24+) shipped in the
Dockerfile. The existing `__pycache__` skip only filters top-level dirs
for dedup logic, not subdirs within packages, so there is no conflict.

Fixes WIN-2001

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:46:33 +00:00
centdix 5c20d6b4f7 feat: add global ai chat test tools (#9391)
* feat: add global ai chat test tools

* fix: avoid session id in flow test preview

* test: cover global flow preview ids

* test: require script and flow test tools

* fix: harden global flow test fallback

* Revert "fix: harden global flow test fallback"

This reverts commit 97254ef33a.

* fix: fallback from inactive flow test hook

* fix: list nested flow steps in errors
2026-06-01 15:43:07 +00:00
GuilhemandClaude Opus 4.7 075faabf3b feat(frontend): surface local drafts in drawer editors with an unsaved-changes banner (#9335)
* feat(frontend): surface local drafts in drawer editors with an unsaved-changes banner

Drawer-based editors (the 11 trigger types, plus resource and variable)
restore unsaved edits from browser localStorage on open using the same
mechanism as flows/scripts, but only showed a transient "Reset to deployed"
toast with no way to review the diff.

Add a persistent "You have unsaved changes" banner below the drawer header
with Show diff / Discard actions, shown whenever the form diverges from the
deployed baseline. Replaces the toast for these editors; flows/scripts/apps
(full-page) keep their existing toast.

- new shared LocalDraftBanner.svelte (Alert-styled bar + DiffDrawer)
- DrawerContent: optional `banner` snippet rendered below the header
- useTriggerDraftSync: reactive `hasDraft`, `deployed`/`current` getters and
  `resetToDeployed`; drop the restore toast (banner supersedes it)
- wire the banner into all 11 trigger editors + variable; resource lifts its
  dirty state up to ResourceEditorDrawer via a callback + accessors
- fix ScheduleEditorInner.openNew not resetting initialConfig (reused editor
  instance kept a stale baseline, wrongly flagging a new schedule dirty)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): address PR review on local-draft drawer banners

- LocalDraftBanner: snapshot diff sides at click time so the diff drawer
  doesn't keep updating as the user types behind it.
- VariableEditor / ResourceEditor: scope the banner and its Discard action
  to the selected workspace; the cross-workspace dirty state stays surfaced
  by the existing otherDirty Alert. Forward can_write via a new
  onCanWriteChange callback so the resource banner hides Discard in
  read-only mode (matching the trigger editors).
- useTriggerDraftSync: drop the now-unused path arg from maybeRestore and
  update all 11 trigger editor call sites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): deep-clone fallback in UserDraft.discard to avoid baseline aliasing

When a caller passed a live $state proxy as `fallback` (Variable/Resource
editors handed `initialStates[selected]` to the banner's Discard), the
handle's draft cell ended up sharing the same proxy as the caller's
baseline. Subsequent form edits mutated both sides in lock-step and the
dirty check kept reporting equal, so the banner never reappeared and
the Update button stayed disabled until the drawer was reopened.

Cloning the fallback inside `discard` (via `snapshotDraftValue`) gives
the handle a fresh tree and decouples the two reactive graphs. Trigger
editors already cloned at their call site (resetToDeployed); this just
makes the API self-contained for all callers.

Also switch the variable form's "Audit log for each access" alert from
warning to info — it's informational, not a warning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): honor disabled prop in LocalDraftBanner's diff drawer

The banner's `disabled` prop hid the inline Discard button but the diff
drawer's "Discard changes" action was still wired unconditionally, so a
read-only user could bypass the hidden inline action via Show diff.
Gate the diff-drawer button on the same flag so both surfaces agree.

Flagged by cubic and Codex on PR #9335.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 14:46:30 +00:00
GuilhemandClaude Opus 4.7 e4213c1ab8 feat(flow-ai): constrain flow-group colors to the NoteColor palette (#9343)
The flow AI chat's set_flow_json tool lets the model set a `color` on each
semantic flow group, but nothing told it which colors are valid, so it would
sometimes emit hex codes / arbitrary CSS color names. Those render with
default styling at best and break the group color picker at worst.

- core.ts: the set_flow_json schema `.describe()` and the `groups` system-prompt
  bullet now spell out that `color` MUST be one of the palette names
  (yellow, blue, green, purple, pink, orange, red, cyan, lime, gray) — no hex,
  no CSS colors — and that omitting it lets the editor auto-assign one.
- helperUtils.ts: validateFlowGroups now rejects any color outside that palette,
  sourced from the NoteColor enum so the two can't drift.
- helperUtils.test.ts: tests for reject-unknown / accept-known / accept-omitted.

Split out of the sessions branch (gl/layout-ai), where it had been bundled
into the large feature commit; it's an independent flow-AI improvement.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:15:22 +00:00
GuilhemandClaude Opus 4.7 eadeac248b feat: sessions page with isolated AI chat + flow editor (#9034)
* feat(sessions): chat + editor side-by-side with multi-session state

Introduces the Sessions feature: a workspace where the AI chat and an
editor (flow / script / app / raw-app) sit side-by-side, with each session
having its own AIChatManager instance, history, and target item. Sessions
are persisted across reloads and can be staged into forks for review.

Key pieces:

- sessions/ — SessionWrapper (the split-pane shell), SessionPicker
  (sidebar list), SessionForkBar, SessionWorkspaceBar, FlowEditorView /
  ScriptEditorView / AppEditorView / RawAppEditorView, ForkDiffDrawer,
  sessionRuntime (per-session AIChatManager + draft state),
  sessionState (in-memory + persisted index), sessionUnread, sessionScope,
  appDraftCodec / flowDraftCodec, forkEditUrl, /sessions route.

- WorkspaceItemDrillPicker refactor — extracts WorkspaceItemRow + adds
  surfaceAI drafts, stale-while-revalidate. workspacePicker.ts drops
  explicit invalidate() in favor of always re-fetching in the background.

- ForkDiffDrawer + WorkspaceItemDiffViewer — per-kind diff bodies
  reusable from the compare page. FlowGraphDiffViewer / FlowGraphV2 gain
  inlineDiff forwarding + onHeight callback for equal-height layout.

- Global AI chat sessions plumbing — AIChatManager exports the class +
  adds disabledModes, beforeSend hook, scoped instance context. AIChat /
  AIChatDisplay accept session-only props (wideLayout, emptyHint,
  inputPreface, hideHeader, hideModeSelector, forceDisabled). Chat
  preserved across /flows/add → /flows/edit, /scripts/add → /scripts/edit.

- Draft-first loaders — sessions open drafts when present, otherwise
  seed a draft from the last deployed value via globalDraftStore.
  RawAppEditor / AppEditor / AppEditorHeaderDeploy get newApp prop +
  fixes so draft-only apps can deploy.

- Compare page (/forks/compare) — bigger overhaul to plug into the new
  drawer.

- Sidebar — Sessions entry + unread badge + status dot in
  SidebarContent / MenuButton / SideBarNotification.

- Misc fixes — chat group color palette constraint, deploy_workspace_item
  confirmation dropped, open_preview tool, picker drafts surfacing,
  fork archive/delete buttons on compare page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): bypass UserDraft inside session panes + sessionUnread crash

After merging main's UserDraft PR (#9121) into the sessions branch, two
integration issues surfaced:

1. AppEditor.svelte calls `UserDraft.use<App>('app', path)` at the
   component level — keyed by ($workspaceStore, 'app', path). Sessions
   that haven't materialized a fork yet stay at the user's main
   workspace, so a session targeting an app at the same path as a
   regular /apps/edit tab shared the same LS key. The session would
   read the regular tab's autosave and write its fork-edits back over
   it.

   Gate UserDraft.use on `!getContext('aiChatManager')` — sessions
   inject the manager via setContext, so inside a session pane the
   handle is `undefined`, stateApp falls through to the `app` prop
   the session loaded, and the auto-save $effect bails. Same gate on
   the four UserDraft.remove call sites in AppEditorHeader and
   RawAppEditorHeader so save/deploy from a session pane doesn't wipe
   the LS draft of a non-session tab at the same path.

2. sessionUnread.svelte.ts called useLocalStorageValue at module
   scope. Main's PR added a deep-mutation $effect inside that helper,
   which now requires component-initialization context — every page
   crashed at import time with `Svelte error: effect_orphan`.
   Replaced with a plain module-level $state + manual localStorage
   persist; same reactivity contract for callers.

3. ScriptEditorView.svelte was passing a `replaceStateFn` prop that
   ScriptBuilder dropped on main. Removed.

Verified end-to-end with Playwright:
- /flows/edit/{path} regression: UserDraft handle still created, no
  console errors
- /sessions loads, sessionUnread doesn't crash
- Session targeting non-raw app `u/admin/userdraft_collision_test`
  displays the fork content (FORK_ONLY_MARKER) even with an LS
  poison at `userdraft/w/local/app/{path}` containing a
  POISONED_BY_REGULAR_TAB_AUTOSAVE marker; poison remains untouched
  after the session loads and renders

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): stop fork-create retry loop on first user message

Removed the SessionWrapper $effect that retroactively committed the
session's workspace from the in-memory chat history. When opening a
session whose previous commit attempt had failed (or whose response was
lost) the effect ran in a tight retry loop, flooding the user with
`workspace_pkey` violations from `create_workspace_fork`.

The send path already commits through `AIChatManager.beforeSend` →
`commitSessionWorkspace`, which is the deterministic moment-of-action.
The $effect was a redundant reactive bridge that turned every backend
failure into an infinite retry.

Also hardens `materializeFork`/`commitSessionWorkspace` so the most
common cause of the duplicate-key error self-heals:

- `materializeFork` short-circuits when `fork.id` is already in
  `$userWorkspaces` (the previous create actually succeeded, we just
  lost the response). On a `workspace_pkey` catch, refresh the workspace
  list and adopt the existing row instead of toasting an error.
- On a real `materializeFork` failure, `commitSessionWorkspace` now
  drops `pending_fork` so the session falls through to the
  workspace-pick fallback instead of looping on the same broken intent.

* feat(sessions): show EditorHeader breadcrumb in the not-found state

When a session's target item has been deleted or moved, the editor pane
used to render a bare "Script not found at path X" line — leaving the
user with no way to navigate to a different target without backing out
of the session.

Each editor view now renders a `SessionItemNotFound` shell instead: a
real `EditorHeader` (read-only summary, no pen popover) with a
breadcrumb keyed to the missing kind+path, plus the "not found" copy
below. Clicking any breadcrumb segment opens the workspace picker
scoped to that level — pick a replacement and the session swaps target
via the existing `onNavigate` callback.

`SessionItemNotFound` maps `raw_app` to `EditorHeader`'s `kind: 'app'
+ raw_app: true` so the picker routes through `/apps_raw/...`; the
local label still says "Raw app not found" (not "App not found") so
the user knows which surface is missing.

* fix(picker): stop self-feeding fetch effect that OOM'd the tab

The drill picker's $effect watched `scope` and called `ensureLoaded`
on every change. `ensureLoaded` reads `loaded[kind]` synchronously
(to decide whether to show a spinner), so the effect ended up
subscribed to the very signal it fills. Each fetch result wrote
`loaded[kind] = items`; Svelte 5's $state proxy notifies on every
property set even when the reference is unchanged from cache, which
refired the effect, which called `ensureLoaded` again, which awaited
the cached fetch, which wrote `loaded[kind]` again... runaway loop.

In `/scripts/edit/...` the picker's lifecycle stabilised quickly
enough to mask the loop, but in a session pane (multiple warm
sessions, picker kept alive by the surrounding state) the cycle
spun freely — 29.8 million iterations in <100 ms during testing,
enough to OOM Firefox / kill the Chromium tab.

Two changes:

- Replace the scope-watching $effect with an explicit `setScope()`
  helper called from `drill()`, `goUp()`, and `onMount`. Fetch is
  now a callback reaction to user navigation, never a reactive
  consequence of one. No closed feedback cycle is possible.

- Untrack the `loaded[kind]` read inside `ensureLoaded`. The search
  $effect (which loads every kind on first keystroke) is still a
  reactive caller; the untrack stops it from subscribing to the
  signal `ensureLoaded` fills, so the same loop can't form there.

* feat(script-editor): wire initialTestPanelCollapsed through ScriptBuilder

The `initialTestPanelCollapsed` prop was already declared on
`ScriptBuilderProps` (used by the session preview to start the editor
with the run/test pane closed) but never destructured in
`ScriptBuilder.svelte`, so the value silently dropped on the floor
and the test pane always opened.

- `ScriptBuilder.svelte` — destructure the prop and forward it to
  `<ScriptEditor>`.
- `ScriptEditor.svelte` — accept the prop and seed `rawTestPanelSize`
  to 0 when true, while keeping `storedTestPanelSize` at the default
  30 so the user's first toggle expands the pane to a sensible width
  rather than 0.

Regular `/scripts/edit/...` doesn't pass the prop → default `false`
→ panel still opens by default.

* fix(sessions): resolve aiChatManager via context in AskUserQuestionDisplay

Inside a session the chat uses a per-pane AIChatManager injected via context. AskUserQuestionDisplay imported the global singleton, so answers clicked in a session dispatched to the singleton's callback map and the AI loop stalled. Resolve via getContext with singleton fallback, matching ChatMode / ToolExecutionDisplay.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(raw_apps): let preview start in single-view on the preview tab

Add a defaultSplitWithPreview prop (default true). When false (session preview), the editor boots in single view with the preview tab selected: gate the onMount default-file activation, the setActiveDocument auto-activation, and iframeShouldMount so the UI Builder bundler iframe still mounts when preview is the active tab. RawAppEditorView passes defaultSplitWithPreview={false}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(copilot): add get_preview_status tool and make open_preview idempotent

So the assistant can tell whether the session preview already shows the item it just edited, instead of re-opening or re-offering it. Mirrors the open_preview handler plumbing (setGetPreviewStatusHandler) and the session runtime registers it alongside open_preview. open_preview now returns 'already open' when the requested target matches the active session's current target. The system prompt steers the AI to check status before offering. Unit tests cover the no-arg schema, the session-only error, and handler dispatch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): make script preview reactive to AI draft writes

ScriptEditorView read the draft via static UserDraft.get inside an effect, which only subscribes to UserDraft's reactive cell when a live entry exists. None did for the preview path, so the chat's writes (UserDraft.save) only touched localStorage and the open preview never updated. Hold a live handle via UserDraft.useMany (reactive getter so it re-acquires when open_preview swaps the path without remounting) and read inbound through handle.draft, materializing the shared $state cell that bridges the chat's writes to the editor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): make raw-app preview reactive to AI draft writes

Mirror of the script-preview fix. RawAppEditorView read the draft via static UserDraft.get inside an effect, which only subscribes to UserDraft's reactive cell when a live entry exists. None did for the preview path, so the chat's raw-app writes (UserDraft.save / setDraftAndMeta, from write_app_file / patch_app_file / write_app_runnable) only touched localStorage and the open preview never updated. Hold a live handle via UserDraft.useMany (reactive getter so it re-acquires when open_preview swaps the path without remounting) and read inbound through handle.draft. Verified in-browser: an external UserDraft.save live-updates the bound summary in the open preview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): make flow preview reactive to AI draft writes

Mirror of the script/raw-app preview fixes, completing two-way binding for all three session editor kinds. FlowEditorView read the draft via static UserDraft.get inside an effect, which only subscribes to UserDraft's reactive cell when a live entry exists — none did, so the chat's writes (write_flow / patch_flow_json / set_flow_module_code) only touched localStorage and the open preview never updated. Hold a live handle via UserDraft.useMany (reactive getter so it re-acquires when open_preview swaps the path without remounting) and read inbound through handle.draft. Verified in-browser both directions: an external UserDraft.save live-updates the flow header summary and rebuilds the module graph; a preview edit propagates through the debounced save to both UserDraft.get and the chat's getGlobalDraft adapter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(sessions): surface local-storage drafts in fork diff & compare page

Augments the backend fork-vs-parent comparison with browser-local (UserDraft) drafts so a session's uncommitted AI/user changes are visible in the Fork Diff Viewer and the /forks/compare page. Adds forkDraftDiff.ts (augmentForkComparisonWithLocalDrafts + getForkItemValue), a 'local changes detected' / new-draft warning surface (checkbox-slot warning icon, no-op-baseline filtering, dedup), a 'Local draft <> fork' tab in DiffDrawer, and selectTooltip/nonSelectableTooltip plumbing in Row/WorkspaceDeployLayout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Revert "feat(sessions): surface local-storage drafts in fork diff & compare page"

This reverts commit 3cfd858e36.

* fix(sessions): leave for home when switching workspace from the session page

An AI session is scoped to its (forked) workspace, so it makes no sense to keep showing it after the user picks a different workspace. The workspace switcher's link href now points home on the session route (the link navigation wins over onClick's preventDefault), and toggleSwitchWorkspace also redirects home there as a fallback. Session-switching uses a separate path (syncWorkspaceTo), so it's unaffected — which is why reacting at the switcher is more robust than watching workspaceStore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): clear session highlight off the session page; default delete-fork on

Two SessionPicker fixes: (1) only highlight the active session while on the /sessions route — currentSessionId lingers after navigating away, so the row stayed selected in the sidebar; gate the highlight on the route. (2) The 'Also delete forked workspace' toggle in the delete-session modal now defaults to on (the fork is tied to the session and would be orphaned otherwise); resets keep it defaulted-on for the next open. User can still untick it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(copilot): say "local storage" instead of "draft" in write-tool status

The global chat's write tools persist to the browser's localStorage (UserDraft), not a workspace draft. The tool status / result messages now say the item was saved to local storage (and discard says it was discarded from local storage) so users aren't misled into thinking a workspace draft was created. Covers the shared script/flow/trigger/resource/variable helpers and the app tools.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): sidebar collapse, new-session chat, fork delete & not-found nits

- Hide the collapse chevron and make the section header non-interactive when there are no sessions; reset the persisted collapsed state while the list is empty so the first session always appears expanded.
- Stop grafting a recent past chat onto a freshly created session: ensureChatIdsSeeded now skips transient sessions, so the seed only pairs untagged chats with pre-existing sessions.
- After deleting a fork from a session (SessionPicker / SessionWrapper), fall back to the fork's parent workspace when the deleted fork was the active one, instead of stranding the user on a deleted workspace.
- Show a 'Session not found' message (with a New session action) when the URL names a session that doesn't exist, rather than rendering a blank page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(copilot): expose preview tools only to session chats

open_preview and get_preview_status drive a session's side-panel editor, so they only make sense inside an AI session. They were always present in the global tool list and just errored when called outside a session. Now AIChatManager carries an isSessionChat flag (set by sessionRuntime.createRuntime); the GLOBAL-mode branch uses globalToolsFor({ sessionPreview }) to drop the two tools for the regular side-panel chat, and prepareGlobalSystemMessage omits their guidance unless previewTools is set. The module-level handlers + in-tool error guards stay as defense in depth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(flow-editor): move intra-editor chat preservation to its own PR

The beforeNavigate / preserveChatOnDestroy guard that keeps the global FLOW
chat alive across same-flow editor remounts is a standalone global-chat fix,
unrelated to sessions. Split out to #9339; FlowEditor reverts to the plain
session-guarded saveAndClear lifecycle here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(raw-app): pre-boot session editor hidden so files open instantly

In single-view (sessions) the UI Builder iframe was mounted inside a
display:none wrapper while the Preview tab was active, so the VS Code
workbench booted at 0x0, threw in its LayoutService ("Unable to figure
out browser width and height"), and wedged the editor on "Loading
editor" with no recovery when later revealed.

Keep the iframe mounted at the editor area's real width and hide it with
visibility instead of collapsing it: Monaco boots correctly while hidden,
and revealing a file is an instant un-hide (no reload, no relayout, no
latency).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(flow-ai): move flow-group color-palette work to its own PR

The flow-group color-palette guidance + validateFlowGroups guard + tests are
an independent flow-AI improvement, not part of sessions. Split out to #9343;
these three flow files revert to their main state here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): hide the in-editor Flow AI Chat button in the session preview

The flow preview pane in a session already sits next to the session's own AI
chat, so FlowBuilder's in-editor "Flow AI Chat" toggle (which opens the global
singleton chat) is redundant and confusing there. Pass
customUi={{ topBar: { aiBuilder: false } }} from FlowEditorView, reusing the
existing showFlowAiButton gate (!disableAi && customUi?.topBar?.aiBuilder !=
false) that flows down to FlowStickyNode — no new prop needed.

Verified in-browser: the button (WandSparkles) renders in the regular
/flows/edit route but is absent in the session preview for the same flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): mirror /scripts/add for never-saved scripts in editor preview

An AI-created script with no backend version yet left savedScript undefined
in the session preview, which disabled Save draft and hid Show diff. Open it
as a new script (empty initialPath) like /scripts/add so Save draft is enabled
and creates it on first save; seed the path as already-chosen
(initialPathChosen) so the summary->path auto-slug does not rename the
AI-assigned path. On first save ScriptBuilder writes savedScript back through
the bind and flips into edit mode (Save draft + Show diff) without navigating
away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): refresh fork diff count after an editor draft save

The fork-bar diff count reads a cached comparison refreshed only on AI-turn-end or tab refocus. A 'Save draft' in the session editor registers in the backend fork tally asynchronously (~300ms after the create returns), so the count stayed stale until one of those triggers fired. Add SessionRuntime.scheduleForkComparisonRefresh() (re-fetches at 700ms + 2200ms to clear the async tally) and wire it to onSaveDraft in ScriptEditorView and FlowEditorView.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): don't auto-open the settings drawer in script preview

When the AI's open_preview tool previews a never-saved script, ScriptEditorView
passes initialPath='' so ScriptBuilder behaves like /scripts/add. That empty
path also triggered ScriptBuilder's auto-open of the settings drawer, which is
unwanted in the session preview where the AI manages metadata. Pass
neverShowMeta so the drawer stays closed on mount; the Settings button still
opens it manually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): don't host legacy drag-and-drop apps in the editor preview

The session preview pane only hosts code-based items (flow, script, raw
app). Drop the legacy 'app' kind from SessionTarget and the open_preview
tool, and route a legacy app picked in the drill picker to the standalone
/apps/edit editor instead. Removes the now-dead AppEditorView and its
runtime load path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): don't prompt to discard raw-app changes on navigation

In a session the raw-app editor's content is continuously persisted to the
UserDraft (localStorage), so tearing the editor down on navigation loses
nothing. Skip the UnsavedConfirmationModal (and its beforeNavigate guard)
when the editor is mounted inside a session pane; the standalone /apps_raw
editor still shows it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sidebar): pin Help to the bottom instead of floating

The bottom of the sidebar stacked the User/Settings cluster and the Help
block with a fixed ~40px gap between them, plus a bottom margin that kept
Help from sitting flush — so Help appeared to float. Drop those fixed
margins so the cluster and Help stay glued at the bottom with a small gap
and Help is flush, and let mt-auto own the flexible space above the group.
Add pt-4 so the cluster keeps a minimum gap from the Triggers section when
the sidebar runs out of room and that flexible space collapses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): surface diff/discard for AI script drafts in preview, refresh diff on deploy

loadScript built the editor's scriptStore by aliasing and mutating savedScript.val, so the deployed baseline got overwritten with the draft content and the diff compared draft-vs-draft. Clone the baseline before layering the AI draft on top. On load, when the local draft diverges from the saved baseline, surface a toast ('AI saved a local draft') with Show diff (opens the diff drawer with a Discard-draft button) and Discard local draft — mirroring the regular /scripts/edit affordance the session's parallel loader omitted. Also wire onDeploy (alongside onSaveDraft) to scheduleForkComparisonRefresh so the fork diff count refreshes after a deploy, not just on an AI turn or tab refocus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): script preview restore/deploy feedback; drop on-load draft toast

- Implement real restoreDeployed/restoreDraft for the diff drawer: the shared loadScript-based handler was a no-op (loadScript early-returns on the loaded path and would re-read the local draft). Reset the live UserDraft handle to the chosen baseline (deleting the backend draft for 'restore to deployed') so the inbound effect syncs the editor.
- Show a 'Deployed' toast on deploy: the default Deploy takes ScriptBuilder's no-toast branch (the editor navigates away instead); the session stays put, so surface the success toast.
- Remove the on-load 'AI saved a local draft' toast: unnecessary in a session, where the user already expects their changes to be present. Diff/discard remain reachable via ScriptBuilder's Show diff + the diff drawer's restore buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): gate breadcrumb picker draft-merge behind the dev flag

The WorkspaceItemDrillPicker merges localStorage UserDrafts into its
navigable items so in-flight session/chat drafts are reachable. That
merge was ungated, so with the sessions dev flag off it also surfaced
the standalone editors' autosave drafts — they appeared as navigable
rows that 404 on the backend draft fetch. Gate aiDraftsForKind on
isGlobalAiEnabled() so it is a no-op without the flag (no sessions
exist then anyway); inside sessions the merge still works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): reload script/flow editor on client-side breadcrumb nav

Picking a different item in the editor-header breadcrumb picker calls
goto() for a client-side navigation. SvelteKit reuses the same +page
instance across a path-param change, but the script and flow editor
routes captured `draftPath` and the `UserDraft.use()` handle once at
mount and never remounted ScriptBuilder/FlowBuilder. The URL and title
updated while the editor kept showing the previous item's breadcrumb,
summary and content; only a full reload showed the navigated-to item.

Mirror the pattern the app / raw-app editors already use:
- Derive the draft path from the URL and key the handle off it via
  `UserDraft.useMany` (a stable proxy onto the current handle), so the
  reload reads/writes the navigated-to item's draft instead of the
  previous one's — fixing the stale draft-comparison too.
- Gate the builder subtree on a `renderEditor` flag flipped false when a
  navigation kicks off the reload and true once the data is ready, so
  the builder cleanly unmounts and remounts once against stable data. A
  synchronous `{#key}` swap instead races Monaco's async init against
  the torn-down container.
- Flows also reset `nobackenddraft` per navigation so a fresh load
  reconsiders the backend draft.

The unsaved-changes guard is unaffected (it runs in beforeNavigate,
before the remount). The app and raw-app editors already handled this
and are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sessions): sync preview with the deployed version on editor + chat deploy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sessions): reload the preview after a chat raw-app deploy

The deploy-reload-preview callback added previously only fired for script and
flow. Now that the merged deploy_workspace_item tool can deploy raw apps
(bundle + createAppRaw/updateAppRaw), wire raw apps in too. A raw app deploys
under type 'app' but the session preview addresses it as 'raw_app', so the
deploy handler maps 'app' -> 'raw_app'; the runtime open-check gains the
loadedRawAppPath case. syncPreviewWithDeployed already handled 'raw_app'
(discard the local draft + force-reload via loadRawApp), so no runtime change
was needed there.

Adds a unit test asserting deploy_workspace_item(type:'app') notifies the
session handler with { kind: 'raw_app', path }.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): address Claude PR review (3 P1 + 3 P2 + test)

P1:
- Drop the hardcoded placeholder default sessions (u/guilhempw/...). New users
  (empty/cleared/private-browsing localStorage) now start with no sessions and
  see the empty state instead of unresolvable "session not found" rows.
- Scope the preview/deploy tool handlers to the *calling* session. open_preview,
  get_preview_status and the deploy reload handler dispatched via the global
  currentSessionId, so a backgrounded session's tool call mutated the UI-active
  session. The calling session id is now carried in the per-manager tool
  `helpers` (AIChatManager.sessionId, set in createRuntime) and threaded through
  the tool ctx to the handlers, which dispatch to it (falling back to the active
  id only when absent). Keeps backgrounded sessions isolated.
- beforeSend now aborts the send on failure: commitSessionWorkspace throwing used
  to be swallowed, letting the message go out against the wrong workspace
  silently. Now it toasts and returns. Also guarded the unguarded
  listUserWorkspaces refresh in materializeFork's duplicate-key self-heal so a
  second network failure can't rethrow past the toast-and-return contract.

P2:
- disposeRuntime now clears the fork-comparison refresh timers (700ms/2200ms)
  via a new runtime.dispose(), so an evicted/deleted runtime can't fire a stray
  refreshForkComparisonNow/compareWorkspaces after teardown.
- Convert Svelte 4 on:click -> Svelte 5 onclick on the Button components in
  SessionWrapper, SessionForkBar, ForkDiffDrawer, SessionPicker, sessions/+page.
- WorkspaceItemRow's <a href> branch gains role="option" + aria-selected to match
  the <button> branch, for consistent listbox semantics.

Tests:
- core.test.ts: deploy_workspace_item(type:'app') threads the calling session id
  through helpers to the deploy handler ({ sessionId, kind:'raw_app', path }).
- New sessionState.test.ts unit-tests deriveForkStatus + isForkSession across
  all branches (root/fork/unavailable/draft, ahead/behind/diverged/in_sync).

svelte-check 0 errors; 57 frontend unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(copilot): collapse deploy preview-reload dispatch to a type→kind map

Replace the if/else-if that mapped deploy type to preview kind with a single Partial<Record<WorkspaceItemType, ...>> lookup + one if. Non-previewable types map to undefined → no dispatch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(copilot): use getAiChatManager() instead of inlining the context fallback

Six chat components still inlined
`getContext<AIChatManager>('aiChatManager') ?? singletonAiChatManager` even
though aiChatManagerContext.ts already exports getAiChatManager() for exactly
this (the resolve-scoped-instance-or-fall-back-to-singleton pattern, already
used by AIChatDisplay/AIChatInput/AIChatMessage/CodeDisplay). Adopt it in
DatatableCreationPolicy, ChatMode, ToolExecutionDisplay, AIChat,
AskUserQuestionDisplay and flow/FlowAIChat, and drop the now-unused getContext /
AIChatManager / singletonAiChatManager imports (FlowAIChat keeps getContext for
its FlowEditorContext/FlowCopilotContext lookups).

No behavior change — getAiChatManager() is the same resolution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): consistent script deploy → preview sync; trim session deploy menu

Two related session deploy fixes + clarifying comments.

1. Hide the extra deploy-dropdown options in the session script preview. The
   editor always "stays" and is already scoped to a fork, so Deploy & Stay here,
   Fork, Edit in workspace fork, Exit & See details and Export as YAML/JSON make
   no sense there — only "Show diff" is kept. ScriptBuilder gains
   `inSessionPane = !!getContext('aiChatManager')` (same pattern ScriptEditor
   uses) and gates those items. (They were correctly absent for never-deployed
   session scripts but leaked for deployed ones.)

2. Fire onDeploy on every successful script deploy. ScriptBuilder previously
   skipped onDeploy for "Deploy & Stay here" and lib scripts (it just re-pinned
   parent_hash + toasted), so a session preview wouldn't sync after those. Now
   onDeploy always fires with a `stay` flag; route consumers skip navigation when
   stay (behaviour identical to before — stay → toast only, primary → navigate),
   and the session ignores stay and always syncs. With (1) hiding Deploy & Stay,
   this now covers the lib-script-in-session case.

3. Comments: RawAppEditorHeader / AppEditorHeader note that the
   `if (!inSessionPane) UserDraft.remove` guards are intentional — the editor
   doesn't own the localStorage draft in a session (the runtime does, keyed by
   the fork); the session-side equivalent is the View's onDeploy →
   runtime.syncPreviewWithDeployed (discard fork draft + reload to deployed).

svelte-check 0 errors; session dropdown verified to show only "Show diff" for a
deployed script, route deploy menu unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sidebar): single Menubar so bottom menus hover-switch (WIN-1993)

The bottom sidebar group split Settings/Workers/Folders/Logs and Help across
two separate <Menubar> components. melt-ui's hover-to-switch (open menu closes
when another trigger in the same Menubar is hovered) only coordinates within a
single Menubar, so hovering between the two groups left both menus open
(stacked) instead of switching. Collapse them into one Menubar, wrapping each
group in its own flex container to preserve spacing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(editor): gate external code sync behind opt-in syncExternalCode prop

The unconditional `code` prop->Monaco sync effect added for sessions
live-preview ran for every <Editor> caller (14 call sites). Most either
bind:code with their own external-sync (e.g. ScriptEditor) or treat code as
init-only, so a blanket setValue risked clobbering them. Gate the effect on a
new opt-in `syncExternalCode` prop (default off) and enable it only at the two
flow inline-rawscript editors — the case that actually needs external updates
(AI chat editing a flow module's content reflecting live in the preview).

Verified in-browser: AI-driven external edit to a flow step now reflects live
in Monaco, and typing keeps the caret intact (round-trip guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): address P1 review findings (commit-abort, render-stuck, workspace sync)

From the cubic/Claude PR review:

1. beforeSend now aborts the send when the workspace isn't committed. The earlier
   fix only caught a *thrown* error, but commitSessionWorkspace returns undefined
   (never throws) when a staged fork fails to materialise — so the first message
   + its tool calls shipped to get(workspaceStore) (the parent). beforeSend now
   throws on undefined so AIChatManager's catch toasts + aborts.

2. The script/flow edit reload effect set renderEditor=false then called
   loadScript()/loadFlow(); a rejected fetch left renderEditor stuck false, so the
   editor pane vanished and never remounted. Both calls now .catch → toast +
   renderEditor=true (token-safe), so the pane always remounts.

3. SessionWrapper.moveAndActivate now syncWorkspaceTo(target) — moving a session
   off an unavailable workspace was leaving the app pointed at the old one
   (mismatch with moveSessionToNewFork / handleConfirmedDelete).

Test: sessionState.test.ts pins commitSessionWorkspace's failure contract
(returns undefined + drops pending_fork when the fork fails) — the invariant the
beforeSend abort relies on. svelte-check 0 errors; 58 frontend unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): address P2 review findings (cubic)

Draft round-trip:
- appDraftCodec: carry custom_path through runtimeRawAppToDraft /
  applyDraftToRuntimeRawApp (+ seed it in loadRawApp) so a session round-trip
  no longer erases a raw-app draft's custom URL.
- sessionRuntime.loadScript "no draft" path: structuredClone the baseline before
  setting parent_hash — it could alias `result` (= savedScript.val) and corrupt
  the pristine deployed baseline the diff drawer reads.
- FlowEditorView: include `summary` in the inbound/outbound dedup sigs so
  summary-only changes propagate/persist.

Workspace-state on navigation:
- SidebarContent (post-delete) and workspace_settings (post-archive): guard the
  listUserWorkspaces() refresh so a transient failure can't strand the user on
  the just-removed workspace, and refresh the list before switching to parent.
- WorkspaceMenu: keep ?workspace=<id> in the session-page workspace href so a
  modifier/middle click (which bypasses onClick) lands in the right workspace.

UI/keyboard:
- WorkspaceItemRow: indent adds to the px-3 base (calc) instead of replacing it.
- ForkDiffDrawer: ArrowLeft maps a 2-segment file path (f/foo) to its scope
  folder (folder:f/foo) instead of a nonexistent folder:f.
- flows/edit: defer flowBuilder setup (primary schedule, draft triggers,
  loadFlowState) until after the builder remounts (renderEditor=true + tick),
  so reload-time state restoration isn't skipped on the unmounted builder.

svelte-check 0 errors; 58 frontend unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(sessions): unit-test the P1/P2 review fixes (extract pure helpers)

Extract the pure logic touched by the review fixes into small tested helpers
(behaviour-preserving) and add unit tests:

- appDraftCodec.test.ts — custom_path survives the runtime↔draft round-trip (A1).
- forkDiffNav.ts/.test.ts — parentFolderKey (extracted from ForkDiffDrawer):
  ArrowLeft parent resolution incl. the 2-segment-path case (C2).
- workspaceMenuHref.ts/.test.ts — extracted from WorkspaceMenu: session-route
  href keeps ?workspace=<id>; off-session swaps the param (B2).
- flowDraftSig.ts/.test.ts — extracted from FlowEditorView (dedups 3 sig sites):
  the dedup signature includes summary, so summary-only changes propagate (A3).

(commitSessionWorkspace failure-contract test for the beforeSend P1 landed with
the P1 commit.) svelte-check 0 errors; 75 frontend unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): address second-round review (Pi + Codex)

Three findings flagged post-push (cubic was fully addressed in the prior
commits; this commit covers the new ones):

- [P1] commitSessionWorkspace non-fork branch — when a session created
  inside a fork defaults pending_workspace_id to the family root, commit
  set s.workspace_id but never synced workspaceStore. First send's
  logAiChat + tool calls then ran against the wrong (still-fork)
  workspace. Fix: syncWorkspaceTo(ws) after the commit, mirroring the
  pending_fork branch's switchWorkspace(newId).

- [P1] Warm-session live-editor slot hijack — /sessions keeps up to 3
  warm-mounted sessions; UserDraft stores one live editor per
  (workspace, kind). Each editor view unconditionally claimed the slot,
  so a hidden warm session in the same workspace+kind could overwrite
  the visible session's claim — chat actions like discard /
  "the open editor" then resolved to the wrong session. Fix: thread
  isActiveSession from SessionWrapper into Script/Flow/RawAppEditorView
  and gate setLiveEditorDraft on it.

- [P2] ForkDiffDrawer stale per-item raw diff cache — loadedDiffs /
  summaries persist for the drawer's lifetime; fetchComparison refetched
  on each open() but loadDiffFor short-circuited on cached keys, so an
  edit-then-reopen showed fresh counts but stale expanded content. Fix:
  clear both records at the top of fetchComparison.

Tests:
- sessionState.test.ts: 2 tests pinning commitSessionWorkspace's
  workspaceStore sync (mismatch and matching).
- userDraft.test.ts: 3 tests pinning the live-editor slot collision
  (regression), the active-session gate, and cleanup ordering.
- forkDiffCache.test.ts (new): 2 tests for the drawer cache
  invalidation contract via fetchComparison simulation.

Verified end-to-end in browser: P2 (close+reopen drawer triggered an
identical second batch of per-item get fetches), P1#1 (new-session send
from a fork synced localStorage.workspace to root and posted chat to
/api/w/local/...), P1#2 (raw_app slot for workspace=local correctly
follows the visible session across A→B→A switches while both stay
warm-mounted). svelte-check 0 errors; touched test suites green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:22:50 +02:00
Ruben Fiszelandrubenfiszel 32b4771f19 chore(main): release 1.713.1 (#9389)
* chore(main): release 1.713.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-01 09:10:53 +02:00
Ruben FiszelandClaude Opus 4.8 9d9c5038ce fix(api): handle multi-version scripts when removing granular ACL (#9388)
* fix(api): handle multi-version scripts when removing granular ACL

remove_granular_acl built a CTE whose `old` subquery selects one row per
matching table row, then read it back via the scalar subquery
`(SELECT old_write FROM old)` in the RETURNING clause. For the `script`
table the PK is (workspace_id, hash), so a path with multiple deployed
versions yields several rows sharing the same (workspace_id, path). When
two or more versions carried the ACL key, `old` returned multiple rows and
PostgreSQL rejected the scalar subquery with "more than one row returned by
a subquery used as an expression", making it impossible to remove an ACL
entry from a script's permissions panel.

All versions share the same extra_perms value (the UPDATE applies to every
matching row), so any single row's old_write is representative. Add
`LIMIT 1` to the scalar subquery. Other kinds are unaffected because they
have a unique constraint on (workspace_id, path/name).

Introduced by b3603d872 (#7365). Add a regression test reproducing the
multi-version case at the SQL level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: remove granular ACL regression test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: trim ACL fix comment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 06:55:24 +00:00
Ruben Fiszelandrubenfiszel b16828d480 chore(main): release 1.713.0 (#9369)
* chore(main): release 1.713.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-31 08:43:37 +00:00
Ruben FiszelandClaude Opus 4.8 edf340c4d4 fix(security): re-pin cached hub scripts to CVE-patched versions (+ HUB_BASE_URL override for cache mode) (#9387)
* feat(cache): allow overriding hub base url via env in `cache` mode

The `windmill cache hubPaths.json` prebuild step (run in the Dockerfile) never
connects to the DB, so HUB_BASE_URL stays at its compiled default
(https://hub.windmill.dev) — unlike server/worker modes which load it from the
DB global setting. This made it impossible to point the prebuild cache step at
a private or staging hub.

Read HUB_BASE_URL from the environment at the start of cache_hub_scripts and
store it into the existing HUB_BASE_URL ArcSwap (the same static the hub fetch
functions read). No effect unless the env var is set and non-empty; server and
worker modes are unchanged (they still use the DB setting).

This also enables validating hub-script dependency changes end-to-end against a
local fake-hub before pushing to the real hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): re-pin cached hub scripts to patched versions

windmill-integrations#133 was merged and pushed to the hub, minting new
versions with regenerated (CVE-free) lockfiles. Bump the hubPaths.json pins so
the prebuild cache step (`windmill cache`) fetches the patched lockfiles instead
of the old vulnerable ones (the hub serves each version_id immutably, so the old
pins keep returning the vulnerable deps until bumped).

- slackErrorHandler     19741 -> 28241
- slackRecoveryHandler   9080 -> 28239
- slackSuccessHandler   28220 -> 28240
- smtpReport             9086 -> 28242
- appReport             28076 -> 28243  (puppeteer screenshot script)
- gitInitRepo           28219 -> 28229  (already-fixed hub version; pin was stale)

Validated end-to-end against the real hub: `windmill cache` with these pins
produces a clean cache_nomount/bun (axios 1.16.1, form-data 4.0.5,
follow-redirects 1.16.0, nodemailer 8.0.10, ws 8.21.0, svelte 5.55.8,
devalue 5.8.1; basic-ftp and ip-address no longer pulled). No vulnerable
versions remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:28:27 +00:00
d23d8374dd use libgnutls30 in slim images (#9286)
* Harden slim image security surface

* Limit slim hardening to libgnutls pin

* fix(docker): pin libgnutls30 to exact +deb12u7 in slim images

Replace the +deb12u* wildcard with the exact current security build so
builds fail loudly when Debian ships a newer patch, prompting an
explicit review/bump rather than silently floating forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(docker): drop libgnutls30 version pin, keep explicit listing

The version pin (exact or wildcard) was not the load-bearing part of
this change. Naming libgnutls30 explicitly in apt-get install is what
forces apt to upgrade it past the base image's older pre-installed
version — transitive deps from wget/curl/git would otherwise leave it
in place because their version constraints are already satisfied.

Dropping the version specifier so each rebuild picks up the current
security build automatically, matching the PR's stated intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(docker): drop libgnutls30 comment

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 08:17:37 +00:00
Ruben FiszelandClaude Opus 4.8 f0301b1605 feat(flows): preserve step/subflow worker tags under a custom-tagged flow (#9375)
* feat(flows): preserve step/subflow worker tags under a custom-tagged flow

A flow running on a custom worker tag force-propagates that tag to every
descendant step, script and nested sub-flow, overriding their own declared
tags. This made it impossible to route a specific step or sub-flow to a
different worker group. The new opt-in FlowValue.preserve_step_tags lets a
step that declares its own non-empty tag run on it; untagged steps still
inherit the flow tag. Defaults off to preserve existing behavior.

* chore: regenerate system prompts for preserve_step_tags

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(flows): nest preserve_step_tags toggle under flow worker tag setting

The toggle only affects routing when the flow has a custom worker tag, so
show it as a sub-setting of the Worker Group tag picker, visible only once a
tag is set, instead of as a standalone option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(flows): allow step worker tag picker when preserve_step_tags is enabled

When a flow defines a worker tag, the per-step tag picker was replaced by a
read-only "Flow's WG" label. With preserve_step_tags enabled the step's own
tag is honored, so the picker must remain editable in that case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(flows): propagate preserve_step_tags to branch and loop bodies

payload_from_modules built the synthetic RawFlow for branch/loop bodies with
a default FlowValue, dropping preserve_step_tags. Tagged steps inside a
branch or loop therefore still inherited the parent flow tag even with the
flag enabled. Thread the flag through to the synthetic FlowValue so the
behavior is consistent for nested containers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(flows): clear preserve_step_tags when flow worker tag is removed

Avoids the flag lingering as invisible state after the flow tag (and its
toggle) are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(flows): repair preserve_step_tags propagation to branch/loop bodies

The previous commit added flow.preserve_step_tags at the payload_from_modules
call sites but the parameter and FlowValue field were not actually threaded
through (a failed edit left the function unchanged), so the crate did not
compile. This completes the change: payload_from_modules takes preserve_step_tags
and sets it on the synthetic FlowValue for branch/loop bodies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(flows): complete preserve_step_tags propagation to branch/loop bodies

Previous two commits left windmill-worker uncompilable: payload_from_modules
received flow.preserve_step_tags at its call sites but the parameter and the
synthetic FlowValue field were not actually added. This adds the parameter,
sets preserve_step_tags on the synthetic FlowValue, and threads
flow.preserve_step_tags through all five call sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(flows): clear preserve_step_tags whenever the flow worker tag is removed

The flag was only reset when the Worker Group toggle was switched off, not
when the tag was cleared directly in the picker (or via the YAML editor),
leaving preserve_step_tags=true as invisible state with the advanced badge
still reporting it active. Move the cleanup into the reactive block that
already tracks the flow tag so every clear path is covered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:10:16 +00:00
Ruben Fiszel 2ddf93de96 fix(auth): enforce monotonic privilege on user token lifecycle endpoints (#9371) 2026-05-30 11:45:42 +00:00
Ruben FiszelandClaude Opus 4.8 def01b8ff6 fix(frontend): sanitize user markdown to prevent stored XSS (#9386)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:40:56 +00:00
fddbe4a51c docs(skills): fix //native marker + import rules for bunnative, remove legacy nativets skill (#9382)
* docs(skills): document mandatory //native marker for bunnative and nativets

* docs(skills): clarify windmill-client is the only allowed library in native mode

* docs(skills): remove legacy nativets skill in favor of bunnative

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): fix bunnative import rule - any bundleable lib, not just windmill-client

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:21:56 +00:00
Diego ImbertandClaude Opus 4.8 2c0c2c467f fix(apps): make public apps opt into cross-origin isolation via wm_coep (GIT-884) (#9374)
* fix(apps): make public apps opt into cross-origin isolation via wm_coep

Public app pages served at /public/* and custom paths /a/* were not
getting the COEP/COOP/CORP headers, so they were blocked when embedded
as an iframe inside a cross-origin-isolated page (e.g. another raw app,
which sets Cross-Origin-Embedder-Policy: require-corp). A nested
document loaded into a require-corp context must itself set COEP for
the iframe to load.

Rather than applying the isolation headers to all public pages (which
would also force COEP on classic apps and break subresources without
CORP, e.g. external image URLs or embeds), public apps now opt in via
a `wm_coep` query param on the embed URL:

    <iframe src="https://<domain>/public/<ws>/<secret>?wm_coep=on">

The app publish drawer gains a URL/Embed toggle: "URL" shows the plain
shareable link (param-free), "Embed" shows a ready-to-copy iframe
snippet with wm_coep baked in, so the flag is discoverable exactly when
embedding and absent otherwise.

`wm_coep` is consumed internally and stripped from the app `query`
context so it doesn't collide with app-defined params. Only params we
own are stripped (an explicit set), not the whole `wm_` prefix.

Fixes GIT-884

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* nit

* nit

* fix(apps): only bake wm_coep into embed snippet for raw apps

AppEditorHeaderDeploy is shared by the classic (AppEditorHeader) and raw
(RawAppEditorHeader) deploy drawers. The embed snippet unconditionally
appended ?wm_coep=on, which for a classic/low-code app forces COEP
require-corp on the document and breaks no-CORP cross-origin subresources
(external <img> in AppImage/AppStatCard/AppNavbar, {@html} embeds in
AppHtml, CDN import() in AppCustomComponent) — the exact regression the
opt-in design avoids.

Add a `rawApp` prop (default false); the raw header passes rawApp. The
flag is appended only for raw apps; classic apps get a plain iframe
snippet, and the wm_coep helper text is shown only for raw apps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 10:05:19 +00:00
centdix f300a716a9 test: add global chat resource variable schedule evals (#9379) 2026-05-30 10:02:36 +00:00
hugocasaandClaude Opus 4.8 b0c3b01d31 fix(cli): preserve user drafts on sync push and permissioned-as (#9381)
CLI deploys (sync push, set-permissioned-as) went through the same
create/update endpoints as a UI "deploy from draft", which delete the
draft at that path. That silently wiped teammates' in-progress drafts on
every push. Add a transient skip_draft_deletion deploy flag (mirroring
deployment_message) that the CLI sets; the backend then skips the
DELETE FROM draft for scripts, flows, and apps. UI deploys are unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:02:11 +00:00
Ruben Fiszel 4b06881918 fix(ai): validate token_url for SSRF in OAuth credentials flow (#9385)
get_token_using_oauth resolved the AI OAuth resource's token_url and
POSTed to it without any SSRF validation, while base_url is validated in
get_base_url. A workspace member with resources:write could point
token_url at an internal/metadata address (e.g. 169.254.169.254),
turning the server into an authenticated blind SSRF probe.

Validate the resolved token_url with validate_url_for_ssrf before the
request, gated behind the same ALLOW_PRIVATE_AI_BASE_URLS opt-in as
base_url so private AI deployments keep working consistently for both
URL fields. ALLOW_PRIVATE_AI_BASE_URLS is now pub so windmill-api can
reuse it instead of re-parsing the env var.
2026-05-30 09:33:27 +00:00
centdix 3345837574 chore: add gpt-5.5 eval model (#9377) 2026-05-29 15:33:27 +02:00
Ruben FiszelandClaude Opus 4.7 04a08976ae fix: batch encryption-key rotation into one git-sync job (#9355)
* fix: trigger git sync for re-encrypted secrets on encryption key change

When changing a workspace encryption key, the secret variables get
re-encrypted with the new key, but the git sync was only dispatched for
the encryption_key.yaml metadata file. Repos with Secrets sync enabled
were left with stale ciphertexts until the next per-variable deployment.

Now, after the transaction commits, we also dispatch a Variable git sync
event for each re-encrypted secret so the new encrypted values are
pushed to the configured repos. Errors are logged but don't roll back
the key rotation.

Fixes WIN-1994
Fixes #9344

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: batch encryption-key rotation into one git-sync job

Workspace encryption key rotation now re-encrypts every secret variable
and then dispatches a single batched git-sync job carrying the Key event
plus one Variable item per re-encrypted secret. Repos with Secrets sync
enabled receive every new ciphertext in one commit instead of nothing
(previously only `encryption_key.yaml` was pushed) — and instead of N
separate jobs the debouncer might or might not merge.

Wires through the new `handle_deployment_metadata_batch` entry point
added in the companion EE PR; OSS has a no-op shim so the build stays
green.

Adds an integration test (`workspace_encryption_key_git_sync`) asserting
that rotating the key with 3 secret variables in scope produces exactly
one deployment-callback job whose `items` array contains the Key event
+ all 3 variable entries and `skip_secret=false`.

Fixes WIN-1994
Fixes #9344

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump ee-repo-ref for git-sync helper simplification

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: cover non-debouncing git-sync fallback on key rotation

Adds a regression test exercising a workspace whose sync script predates
hub version 28103: the rotation must still queue a legacy-format
deployment-callback job per item (encryption_key + each re-encrypted
secret) instead of silently skipping the repo. Bumps ee-repo-ref to the
EE fallback fix.

Addresses the P1 raised in the PR review (Codex/Pi/Claude): batch path
dropped git sync entirely for repos without sync-job debouncing support.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add sqlx offline cache for encryption-key git-sync test queries

The cargo_test CI job builds with SQLX_OFFLINE=true; the two new
sqlx::query!/query_as! calls in
windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs
had no cached entries, failing the build with E0282. Regenerated and
added only the two new query caches (no EE/feature cache loss).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump ee-repo-ref to updated EE companion PR (08e3b9b)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 05:12:35 +00:00
Ruben FiszelandClaude Opus 4.7 96a8eb63d4 disable redirect following on AI proxy client to close SSRF (#9370)
* fix(ai): disable redirect following on AI proxy client to close SSRF

The AI proxy validates the configured base_url against SSRF rules but the
shared HTTP client followed up to 10 redirects without revalidating the
hops, so a public base_url could 3xx the server into a private/internal
address (e.g. the Docker socket or cloud metadata). Disable redirect
following so the validated host is the only one the server connects to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ai): remove heavy redirect SSRF integration test

Drop the integration-test-level regression for redirect following; it
spins up a full API server + DB for a one-line client-config change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 04:59:47 +00:00
Ruben FiszelandClaude Opus 4.7 bb90f4ce83 fix(api): authorize and harden log-file reading endpoints (#9368)
* fix(api): don't follow symlinks when reading service log files

Defense in depth on top of the existing `..` path-traversal check in
the get_log_file handler: reject the request if the final path
component is a symlink, so a planted symlink in the logs directory
cannot be used to read arbitrary files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): authorize and harden the jobs_u get_log_file endpoint

The unauthenticated jobs_u get_log_file endpoint served any job's log
file to anyone who knew the job UUID, with no authorization. Gate it the
same way as get_job_logs: look up the job (the log directory name is the
job id) filtered by workspace and the caller's scope tags, and only allow
non-logged-in callers to read logs of jobs created by the anonymous user.

Also add defense in depth: refuse to read through a symlink so a planted
symlink in the logs directory cannot be used to exfiltrate arbitrary files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 04:50:31 +00:00
Ruben FiszelandClaude Opus 4.8 12e06bfa6b bump default sync script to hub/28238 (windmill-cli@1.712.0) for fork branch push (#9372)
The fork-branch deployment callback runs the hub script
sync-script-to-git-repo-windmill, which imports `windmill-cli` as a pinned
npm dependency and runs it in-process (it does NOT shell out to a PATH wmill).
hub/28236 pinned windmill-cli@1.706.1, whose `git-deploy --only-create-branch`
path returns early without pushing — so the fork branch was checked out
locally but never published to the remote. #9366 fixed the CLI and shipped it
as windmill-cli@1.712.0, but without a hub-script bump the running callback
still used 1.706.1.

Bump LATEST_GIT_SYNC_SCRIPT_PATH to hub/28238, which is identical to 28236
except it pins windmill-cli@1.712.0 (content + lockfile). This fixes
test_workspace_fork_creates_branch and production fork-branch creation.

Also add backend/windmill-common/src/workspaces.rs to the git-sync-test
path-gate so future script-path bumps trigger the e2e (the bump alone is not
otherwise covered by the gate).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 04:36:53 +00:00
hugocasaandwindmill-internal-app[bot] 2bf11dcb15 feat(oauth): support per-provider sandbox URLs (#9358)
* feat(oauth): support per-provider sandbox URLs in registry + instance settings

* fix(oauth): polish sandbox review nits (cc lookup, header label, ee ref)

* refactor(oauth): drop dead build_oauth_clients duplicate in windmill-oauth

* refactor(oauth): derive sandbox-capable provider list from registry

* chore(docker): copy oauth_connect.json into frontend build stage

* test(oauth): cover sandbox helpers (as_sandbox, canonical_name, resolve)

* chore: update ee-repo-ref to 9297d8f790346e6a6ad540c7bca1a67f91ec11a2

This commit updates the EE repository reference after PR #595 was merged in windmill-ee-private.

Previous ee-repo-ref: 3ab3eca9ac15ebab6db991e7964bc5e48ce21f42

New ee-repo-ref: 9297d8f790346e6a6ad540c7bca1a67f91ec11a2

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-28 22:33:44 +00:00
Ruben Fiszelandrubenfiszel 889101b7f0 chore(main): release 1.712.0 (#9340)
* chore(main): release 1.712.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-28 19:52:36 +02:00
centdix 2553fbfe31 feat: add deepseek fim support (#9365) 2026-05-28 16:52:26 +00:00
Ruben Fiszel 9a659b636d fix(frontend): prevent duplicate asset node ids crashing flow graph (#9367) 2026-05-28 16:16:12 +00:00
2fdc51e629 fix(git-sync): publish fork branch on only_create_branch from the CLI (#9366)
* [ee] fix(git-sync): publish fork branch on only_create_branch from the CLI

Fixes WIN-1997. Forking a git-sync-configured workspace must push a
`wm-fork/<branch>/<id>` branch to the repo, but the integration test
`test_workspace_fork_creates_branch` failed: the fork callback job
succeeded yet no branch appeared.

Root cause: the fork-branch callback runs the sync script with
`only_create_branch: true` and no items. The hub sync script delegates
branch checkout to `wmill sync git-deploy --only-create-branch` and runs
its own in-process commit+push ONLY for the `!only_create_branch` path
(`if (!only_create_branch) git_push(...)`). #9284 had moved commit+push
out of the CLI to the caller for the GPG-cache-warmth invariant
(WIN-1974) — but it also dropped the CLI's push for the branch-only
case. A branch-only publish has no commit, so no signing is involved and
the GPG concern does not apply; with neither the CLI nor the hub script
pushing, the empty fork branch was never published.

Restore the CLI push for the `only_create_branch` path (a bare
`git push --porcelain` of the checked-out branch ref). Adds a
deterministic CLI regression test that runs `git-deploy
--only-create-branch` for a fork workspace and asserts the branch
reaches the remote with no caller-side push.

EE companion: format the fork-branch commit message with Display instead
of Debug (no more `Some("...")` leak).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd

This commit updates the EE repository reference after PR #597 was merged in windmill-ee-private.

Previous ee-repo-ref: 8b02336fcebdfae4b9d2795cbb74fa7046530bcb

New ee-repo-ref: a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-28 16:13:38 +00:00
centdix a7d85a39ff refactor: clean up ai provider proxy logic (#9360)
* refactor: clean up ai provider proxy logic

* docs: remove completed ai refactor plan

* fix: audit failed google global proxy calls
2026-05-28 16:05:04 +00:00
Ruben Fiszel 045d12043e feat(queue): duration-weighted fairness admission (#9334)
* [ee] feat(queue): duration-weighted fairness admission atomic

Add the `WORKSPACE_FAIRNESS_ADMISSION_PPM` atomic that the EE
`workspace_fairness_ee::refresh_overloaded` writes on each refresh
(see companion EE PR). The atomic is read on every pull by
`should_admit_capped` to decide whether the dispatch goes down the
standard or fairness path. Defaults to 10_000 (= admit all) so the
pre-fairness behaviour is preserved until the first refresh fires.

OSS stub in `workspace_fairness.rs` continues to return `true`
unconditionally, so non-EE builds are bit-identical.

* docs(queue): consolidate full fairness algorithm into workspace_fairness.rs

Move the algorithm doc — what "overloaded" means in worker-seconds, the
duration-weighted admission derivation, coordinated refresh structure,
audit emission, the SQL perf constraints (no params CTE, drive running
side from v2_job_runtime), and EE gating — into the OSS surface module
where it is readable without EE access. The EE file becomes implementation
only.

Also bump ee-repo-ref to the EE commit that strips the duplicate doc.

* docs(queue): clarify ADMISSION_PPM default is "admit all", not count-based

Addresses CI review (claude[bot]): the `10_000` initial value is the
"admit all" no-op default that applies before the first refresh
classifies an overloaded set — not the count-based value (which would
be `target * 10_000`). The count-based form is the empty-bucket fallback
inside `compute_admission_ppm`, a different thing.

* chore(queue): point ee-repo-ref at EE main (fairness admission merged via #593)

* fix(queue): duration-weighted admission uses unclamped service-time window

Bumps ee-repo-ref to the EE fix (windmill-ee-private#596) that sources
D_c/D_u for the admission probability from a separate 60s service-time
window of true `duration_ms`, instead of the occupancy aggregation whose
per-job contributions are clamped to the 10s occupancy window. The clamp
truncated D_c for capped jobs longer than the window, under-admitting the
duration skew (true 34s jobs → ~86% effective share instead of the target
65%). Occupancy worker-seconds still drive overload classification.

Updates the algorithm doc in workspace_fairness.rs accordingly.

Note: ee-repo-ref points at the EE feature branch; re-point to EE main
once #596 merges.
2026-05-28 14:53:05 +00:00
Ruben FiszelandClaude Opus 4.7 aea00611c4 fix(frontend): prevent MultiSelect crash on undefined value (#9364)
MultiSelect read `value.length` directly while `value` is a bindable
prop with no default, so a parent passing `undefined` (e.g. an
enum-array approval form field with no initial value via ArgInput)
threw a TypeError that blanked the entire approval page. Guard all
reads behind a `value ?? []` derived.

Fixes WIN-1996

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:48:54 +00:00
centdix 9e7eaf3684 feat: inject active editor into global chat (#9361) 2026-05-28 13:57:49 +00:00
Diego Imbert a9e5140995 feat: warn when custom instance db is shared across workspaces (#9359)
* feat: warn when custom instance db is shared across workspaces

* Fix leaking workspace names

* sqlx prepare
2026-05-28 13:57:22 +00:00
hugocasaandClaude Opus 4.7 c2b5ba8871 fix(cli): stop re-prompting on wmill refresh prompts (#9357)
referencesIncludeLine required the include token to be the entire
trimmed line. The wmill-default CLAUDE.md template is
`Instructions are in @AGENTS.md` — include mid-sentence — so the
migration prompt fired every run on files wmill itself wrote.

Accept the include as a whitespace-separated token on any non-comment
line.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:31:25 +00:00
GuilhemandClaude Opus 4.7 4efc37212a fix: infer script arg schema when deploying via AI chat (#9356)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:16:21 +02:00
Ruben FiszelandClaude Opus 4.7 da882c54b2 fix(frontend): close other sidebar menus when hovering Help (#9354)
The Help menu lived in a separate Menubar from the Settings/Workers/
Folders/Logs group, so melt-ui's hover-to-switch logic (which only
spans menus within the same Menubar) did not close the Help popup
when the cursor moved to a sibling group, causing menus to stack.

Merge both bottom Menubars into a single Menubar, wrapping each
group in its own flex container to preserve the visual spacing.

Fixes WIN-1993

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:30:34 +00:00
centdix dec58e6c4f feat: deploy raw apps from global chat (#9349)
* feat: deploy raw apps from global chat

* fix: require raw app bundle protocol

* chore: bump ui builder artifact

* docs: explain app custom path deploy handling
2026-05-27 18:55:15 +02:00
Diego Imbert f947b1dfdf fix (frontend): schedule "View runs" url (#9350) 2026-05-27 13:49:02 +00:00
centdix e29dfbaa87 test: add global chat eval coverage (#9320)
* test: improve global chat eval parity

* test: add human-style global chat evals
2026-05-27 12:19:57 +00:00
Ruben FiszelandClaude Opus 4.7 88056f8d4c fix(cli): redact encryption_key diff in stdout by default (#9347)
* fix(cli): redact encryption_key diff in stdout by default

Sync diff output previously printed the full encryption_key contents on
stdout whenever the workspace key changed locally or on the remote, which
made it easy to leak the key via shell history, CI logs, etc. Now the
diff is replaced with a redacted notice for any encryption_key change in
both prettyChanges and showConflict. Pass --show-encryption-key-diff
(also configurable via wmill.yaml's showEncryptionKeyDiff) to opt back
into the full diff.

Fixes WIN-1992

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cli): redact encryption_key diff with fixed-length mask

Drop the --show-encryption-key-diff opt-in and always redact: the diff
now keeps the first 5 chars of the key so rotations are still visible
(different prefixes), then replaces every remaining char with `*` so the
length of the key is preserved without leaking it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:28:11 +00:00
Diego Imbert ae2222febf prevent path component from wrapping (#9345) 2026-05-27 07:59:02 +00:00
centdix 9752f0f909 docs: remove stale planning notes (#9342) 2026-05-27 07:19:51 +00:00
Ruben FiszelandClaude Opus 4.7 59ab038d77 fix(monitor): cleanup stale server_heartbeat background_task_state rows (#9338)
`announce_server_started` writes a `server_heartbeat:{INSTANCE_NAME}` row
on each startup. INSTANCE_NAME is a fresh random string per process, so
the row is never updated again and a new row is inserted on every
restart, growing background_task_state unboundedly.

Add an hourly monitor task that deletes server_heartbeat:* rows older
than 7 days. Older rows cannot influence check_any_server_started (which
only considers heartbeats refreshed after the restart was initiated), so
they are safe to prune.

Fixes WIN-1990.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:36:53 +00:00
Ruben Fiszelandrubenfiszel 8d72a7a4a4 chore(main): release 1.711.0 (#9337)
* chore(main): release 1.711.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-26 16:47:53 +00:00
hugocasa 6f770346fb fix(cli): handle __flow suffix when deriving the flow's Windmill path (#9333) 2026-05-26 16:40:46 +00:00
Ruben Fiszel 42d2121af9 fix(queue): duration-weighted workspace fairness signal (#9329)
* fix(queue): bump EE ref to include worker_ping fairness signal

The current ee-repo-ref.txt pointed to 31cda7c (an unrelated merge
commit on the asset-graph-view-ee branch) instead of ddc9e80, which
contains the workspace-fairness fix that switches the active-share
signal from v2_job_queue.running=true to worker_ping. As a result
cloud was still computing overload off the legacy signal, so a
workspace with many in-flight/suspended flows (lancom01-prod, with
799 suspended flows × 3 v2_job_queue bookkeeping rows each = 2397
running-true rows) was flagged as 95% of cluster activity despite
consuming zero worker slots.

Bumping to ddc9e80 picks up the worker_ping-based signal, which
naturally excludes (a) suspended jobs (no worker pinging them),
(b) zombie running-rows from dead workers, and (c) flow/flownode
orchestration rows that never run on a worker in the first place.

* test(queue): seed v2_job rows + realistic durations for fairness helpers

The new duration-weighted fairness algorithm joins v2_job_queue and
v2_job_completed to v2_job for the `kind` filter (excluding flow
bookkeeping) and reads `duration_ms` for the completed contribution.
Update the test helpers to mirror that schema:

* `insert_completed` now inserts a matching v2_job row (kind=script)
  and writes `duration_ms = 1000` with a 1-second [started_at,
  completed_at] interval, so each completed row contributes ~1
  worker-second when fully inside the refresh window.
* `insert_queued` likewise pre-inserts v2_job, sets `started_at`
  to NOW() - 1s when running=true (so running rows contribute ~1
  worker-second by the time the refresh runs), and seeds
  v2_job_runtime.ping so the running side accrues real-time worker
  seconds (the algorithm bounds end-of-interval by ping).

The zombie/suspended insert helpers are intentionally left without
v2_job rows — the new algorithm's INNER JOIN excludes them, so they
still correctly contribute zero worker-seconds.

* chore(queue): bump EE ref to duration-weighted fairness algorithm

Companion to windmill-ee-private#<TBD>: switch the EE workspace
fairness aggregation from a count-based UNION (worker_ping snapshot
+ v2_job_completed count) to a worker-seconds aggregation sourced
directly from v2_job_queue and v2_job_completed, with kind/suspend
filters mirroring handle_zombie_jobs and per-row defenses against
zombie inflation on both halves.

* chore(queue): bump EE ref for fairness perf fix (inline window_start)

* chore(queue): bump EE ref for fairness perf rewrite (driver-side flip)

* update ee ref
2026-05-26 11:47:45 +00:00
hugocasa 979b086b08 refactor(cli): fold flow test-step into flow preview --step (#9330) 2026-05-26 11:21:54 +00:00
hugocasa 36f574ff95 feat(cli): add object-storage commands and flow test-step (#9326)
* feat(cli): add object-storage commands and flow test-step

* docs(cli): clarify flow test-step doesn't recurse into aiagent tools

* fix(cli): correct failure step id in docs, handle bare flow.yaml path
2026-05-26 10:43:43 +00:00
Ruben Fiszel a6c51b146d ignore flaky fairness regression tests in CI (#9328)
`fairness_ignores_zombie_running_rows` and
`fairness_ignores_concurrency_suspended_rows` panic intermittently in CI
(both Linux and Windows runs). Mark them `#[ignore]` until the
underlying flakiness is resolved.
2026-05-26 10:22:54 +00:00
Ruben Fiszel 85a128cc34 prevent windows backend tests from running out of disk space (#9325) 2026-05-26 10:15:45 +00:00
Ruben Fiszelandrubenfiszel edea1b3631 chore(main): release 1.710.1 (#9327)
* chore(main): release 1.710.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-26 09:38:13 +00:00
Ruben Fiszel 896add0350 fix: improve workspace fairness 2026-05-26 09:25:45 +00:00
Ruben Fiszelandrubenfiszel 80f6a5a6e8 chore(main): release 1.710.0 (#9323)
* chore(main): release 1.710.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-26 06:34:55 +00:00
Ruben Fiszel 6f363163df fix(websocket-trigger): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (#9324)
* feat(websocket-trigger): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (WIN-1988)

`tokio_tungstenite::connect_async` opens a raw TCP socket and ignores
the standard outbound-proxy env vars, so deployments behind a forward
HTTP proxy can't reach the WebSocket endpoint and Test Connection
times out after 30s.

Add a small `proxy` module that resolves the right proxy URL for the
target host (HTTPS_PROXY for wss://, HTTP_PROXY for ws://, NO_PROXY
exclusions, ALL_PROXY fallback, lowercase variants), opens an HTTP
CONNECT tunnel when one applies, and hands the resulting TcpStream to
`client_async_tls_with_config` for the TLS + WS handshake. Direct
connect remains the default when no proxy env is set.

Unit tests cover NO_PROXY matching, proxy URL parsing (including IPv6
literals and basic-auth userinfo), and the CONNECT handshake itself
against an in-process fake proxy (success, basic-auth header, 407
rejection).

Fixes WIN-1988

* refactor(websocket-trigger): reduce blast radius and reuse existing logic

Follow-up to the proxy support change. Three things:

1. Skip the new code path entirely when no proxy is configured.
   `connect_async_with_proxy` now checks the env-var snapshots up front
   and delegates straight to `tokio_tungstenite::connect_async` if
   neither `HTTP_PROXY` nor `HTTPS_PROXY` is set. Same fall-through
   applies when proxy env is set but `NO_PROXY` excludes the host or
   the proxy URL doesn't parse. Non-proxied deployments now exercise
   exactly the previous code path.

2. Move the `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` env-var snapshots
   from `windmill-worker::worker` into `windmill-common`. The worker's
   `PROXY_ENVS` static now reads from there, and the websocket trigger
   reads from the same source — one place reads the env, one source
   of truth for both call sites.

3. Replace the hand-rolled proxy-URL parser with `url::Url::parse`
   (already a workspace dep, used across the codebase). Half the LoC
   and handles edge cases (userinfo percent-encoding, IPv6 literals,
   path/query stripping) via the well-tested crate instead of by hand.

All 13 proxy unit tests still pass. `cargo check` is clean.

* fix(websocket-trigger): unbreak EE build + trim proxy tests

- Re-export `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` from
  `windmill-worker::worker` (via `pub use windmill_common::...`) so the
  EE `otel_tracing_proxy_ee` module's `use crate::{HTTPS_PROXY, ...}`
  resolves like it did before. Fixes the `check_ee_full` / `cargo_test`
  CI failures from the previous commit.

- Trim the proxy tests to one un-ignored canary
  (`http_connect_tunnel_sends_well_formed_request_and_unwraps_stream`)
  that exercises the actual on-wire CONNECT handshake plus byte-perfect
  tunnel passthrough. The NO_PROXY-matching, URL-parsing, and edge-case
  tunnel tests are kept under `#[ignore]` for manual debugging
  (`cargo test -- --ignored`) since they're either delegated to
  `url::Url::parse` or trivial string matching — low ROI on every CI run.
2026-05-26 06:17:07 +00:00
centdix 4be930f585 refactor: unify AI provider credentials (#9317)
* refactor: use provider credentials for worker builders

* refactor: resolve api proxy credentials directly

* fix: lazy load frontend eval modes
2026-05-26 05:51:32 +00:00
Ruben Fiszel 8bf7fd2c92 feat(queue): stochastic admission + EE availability of workspace fairness algorithm (#9321) 2026-05-26 04:51:53 +00:00
Ruben Fiszel a28a68258c add cli-sync workspace snapshot/load scripts (#9322)
* feat(fixtures): add cli-sync workspace snapshot/load scripts

* fix(fixtures): address review nits (env var password, mktemp, dead refs)

* fix(fixtures): address CI review (SIGPIPE, JSON escaping, doc/code drift)
2026-05-26 04:35:05 +00:00
Ruben Fiszelandrubenfiszel f9c7fa2e43 chore(main): release 1.709.0 (#9312)
* chore(main): release 1.709.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-25 18:19:53 +00:00
GuilhemandClaude Opus 4.7 90a196d8d8 feat(raw_apps): surface UI Builder build errors over the preview pane (#9316)
* feat(raw_apps): surface UI Builder build errors over the preview pane

Companion to the matching change in the UI Builder repo (see linked PR),
which stops rendering the build-error overlay over the VS Code editor
iframe and instead emits a `buildError` postMessage on every build
(message: undefined on success to clear).

Listen for that message on the existing window message handler (already
source-gated by the UI Builder iframe), store it in a `buildError`
$state, and surface it in two places:

* A red banner over the preview iframe, sibling to the existing logs
  overlay (`top-12 left-2 right-2 z-20` so it clears the tab bar) —
  failures appear right where the user looks for the rendered output.
* The Preview tab's icon and label tint red
  (`text-red-600 dark:text-red-400`, matching the existing error
  convention in raw_apps) — important in single-tab mode where the
  preview pane is collapsed to 0px and the banner would be hidden.
  Done by mapping `leftPaneTabs` / `rightPaneTabs` through a small
  `tintPreviewOnError` helper so the source-of-truth `tabs` array is
  untouched (DnD, ordering, fallback selection keep using the original
  previewTab object).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(raw_apps): use Alert component for the build-error banner

Replace the hand-rolled red div with the shared `Alert` component
(`type="error"`, `title="Build failed"`). The error text stays in a
`<pre>` child so multi-line bundler output keeps its formatting, with
`max-h-60` so a long error never takes over the whole preview pane.

The absolute-positioned wrapper (`top-12 left-2 right-2 z-20`) and the
`role="alert"` move to that wrapper so the Alert component itself stays
unstyled at the call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(raw_apps): solid bg-surface backing behind build-error Alert

The Alert's error background is semi-transparent in dark mode
(`bg-red-900/40` in `common/alert/model.ts`), so the preview iframe
shows through when the banner is laid over it. Add a `::before`
pseudo on the Alert root with `bg-surface` (matched `rounded-md`,
`-z-10` so it sits behind the red bg) to give it a solid plate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(raw_apps): isolate banner stacking context, DRY tab tint chain

Two small follow-ups from review:

* Add `isolate` to the build-error banner wrapper so the `before:-z-10`
  pseudo's stacking context is pinned locally — it works today because
  `position: absolute` + `z-20` creates one, but `isolate` makes the
  dependency self-documenting and survives a future refactor that
  removes the explicit `z-20`.
* Extract `tintTabs = (ts) => ts.map(tintPreviewOnError)` so the two
  `$derived` blocks for leftPaneTabs / rightPaneTabs read identically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(raw_apps): trim build-error overlay comments

Per review feedback. Keep only the load-bearing facts (bg-surface backs
the Alert's translucent red, isolate pins the pseudo stacking, the
`message: undefined` clear convention) and drop the prose context that
duplicated what the code already shows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(raw_apps): bump bundled ui_builder to 00c9834

Brings in the postMessage emission from
windmill-labs/windmill-code-ui-builder#9 (merged) so this PR's host
listener actually receives `buildError` events. SHA verified against
the R2 artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:15:14 +00:00
Ruben FiszelandClaude Opus 4.7 108a88a180 fix(jobs): authorization bypass in only_result job updates (WIN-1980) (#9301)
* fix(jobs): enforce anonymous-only guard on `only_result` job updates

The `jobs_u/getupdate/{id}` and `jobs_u/getupdate_sse/{id}` endpoints
accept `only_result=true`. In that branch, `get_job_update_data` queried
the result solely by (workspace_id, job_id) and skipped the
`created_by == "anonymous"` check that the non-only_result path and
adjacent unauthenticated endpoints apply. An unauthenticated requester
who learned a private job UUID could therefore retrieve that job's
output.

Hoist the guard to the top of `get_job_update_data` so both branches are
covered.

Fixes WIN-1980

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: fold `created_by` check into existing only_result queries

Avoids the extra `SELECT created_by` round-trip per call by joining
`v2_job` once in the two queries that handled the unauth path and
checking inline. Behavior is identical to the prior commit; the SSE
polling loop now does one query per poll instead of two for
unauthenticated callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: cache anonymous_verified across SSE polls

Replace the LEFT JOIN approach with an upfront `SELECT created_by`
guarded by a new `&mut bool anonymous_verified` parameter that mirrors
`early_return_suppressed`. The SSE polling loop now performs the auth
check exactly once per stream rather than per poll, and the data SQL
reverts to its original form so authenticated callers pay no extra
cost. `created_by` cannot change after job creation, so caching the
verification across polls is safe.

Cost matrix:
- Authed (any path): 0 extra queries
- Unauthed one-shot: 1 extra query (unavoidable)
- Unauthed SSE: 1 extra query at stream start, 0 per poll

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: scope anonymous check to only_result branch

The non-only_result branch already enforces the `created_by` check via
its main query, so a top-level hoisted check duplicated work for
unauthenticated default-path callers. Move the check inside the
`if only_result.unwrap_or(false)` block — exactly where the bypass
lives — and leave the non-only_result path untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:24:57 +00:00
Ruben Fiszelandwindmill-internal-app[bot] b125eca762 feat(service-accounts): allow choosing role at creation time (#9307)
* [ee] feat(service-accounts): allow choosing role at creation time

Previously, service accounts were hardcoded to operator and could not be
used as the CLI sync user since they had no write access. They also only
counted as 0.5 seat each.

This change:
- Extends `NewServiceAccount` to accept optional `is_admin` / `operator`
  (defaults to `operator=true` for backward compatibility).
- Exposes a role picker in `AddUser.svelte` when creating a service
  account (Operator / Developer / Admin).
- Lets admins update a service account's role from the user list (it
  used to be locked to "Operator" with a tooltip).
- Updates the OpenAPI spec + regenerates the frontend client.

A developer/admin service account counts as 1 seat under the existing
seat-cap logic (operators stay at 0.5).

Companion PR on windmill-ee-private updates the `INSERT INTO usr` to
honour the chosen role.

Fixes WIN-1985

* [ee] feat(service-accounts): wm_deployers opt-in for Dev role

When creating a service account with role=Developer, surface a toggle
"Add to wm_deployers" (recommended). Members of wm_deployers can deploy
on behalf of other users — the typical setup when the service account is
used as the CLI sync / CI deploy identity.

- `NewServiceAccount` gains an optional `add_to_deployers` flag.
- Frontend defaults the toggle to on but only shows it under Developer
  (admins have it implicitly; operators can't deploy).
- Tooltip links to docs.windmill.dev "Run on behalf of".

Companion EE PR updates the handler to INSERT into usr_to_group for
wm_deployers when the flag is set.

Refs WIN-1985

* chore: update ee-repo-ref to 974ed42067d9f63acb42332b671b8c01ffd4b625

This commit updates the EE repository reference after PR #589 was merged in windmill-ee-private.

Previous ee-repo-ref: f7dbc3cc2ba21c396f4828881e3b9d9ab6f50c69

New ee-repo-ref: 974ed42067d9f63acb42332b671b8c01ffd4b625

Automated by sync-ee-ref workflow.

* [ee] fix(service-accounts): unhardcode role in superadmin user list

Two review issues from the merged #9307 / #589:

1. P1 — The global Users tab in #superadmin-settings still pinned every
   service account to "Operator". Now it shows the actual role
   (Admin / Operator / Developer), derived from the SA's usr row.

   - `list_users_as_super_admin`: replaced `true as operator_only` with
     the real `operator` value, and added `is_workspace_admin` from the
     row (NULL for password users since their admin status is
     per-workspace).
   - `global_whoami`: when the email belongs to a service account, look
     up its real `operator` / `is_admin` instead of pinning to operator.
   - `SuperadminSettingsInner.svelte`: drop the hardcoded "Operator"
     badge; render Admin / Operator / Developer using the new fields,
     matching the workspace-level view.

2. P2 — Regenerate the bundled `openapi-deref.{yaml,json}` so the
   `createServiceAccount` body (now exposing `is_admin`, `operator`,
   `add_to_deployers`) and the new `GlobalUserInfo.is_workspace_admin`
   field show up at runtime in `/api/openapi.{yaml,json}`.

Bumps `ee-repo-ref.txt` to the EE follow-up that adds the offline
seat-cap check on `create_service_account`.

Refs WIN-1985

* chore: update ee-repo-ref to b7a6068c1f3dc845e012959268b2426f0de4d697

This commit updates the EE repository reference after PR #590 was merged in windmill-ee-private.

Previous ee-repo-ref: 0b1307c21d1bfd6fb43a03c2ba39d2a8bf8e6470

New ee-repo-ref: b7a6068c1f3dc845e012959268b2426f0de4d697

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-25 16:18:38 +00:00
Ruben FiszelandClaude Opus 4.7 e218d60919 skip workspaced-route duplicate checks on cloud (#9305)
* fix(settings): skip workspaced-route duplicate checks on cloud

The pre-write validation hooks for `app_workspaced_route` and
`http_route_workspaced_route` query the DB for cross-workspace duplicates
and fail the save when any are found. On cloud both `custom_path_exists`
(apps) and `route_path_key_exists` (HTTP triggers) already scope lookups
by `workspace_id` regardless of these settings, so duplicates across
workspaces are expected and the validation has no runtime meaning. The
result was that any cloud super-admin attempting to save instance
settings with these toggles set to false received
`Duplicate HTTP route paths detected` even though the setting has no
effect on cloud routing.

Fixes WIN-1983

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(error): render JsonErr as readable text and return 400

`Error::JsonErr` previously rendered through `#[error("Error: {0:#?}")]`,
leaking Rust's `Debug` output (`Object { "error": String(...), "details":
Array [...] }`) into the HTTP response body, and was bucketed into the
catch-all 500 branch in `IntoResponse`. The result was a 500 status with
a wall of Rust debug syntax in the toast — confusing and user-hostile.

- Bucket `JsonErr` into 400 (Bad Request): every current call site
  (workspaced-route duplicate checks, OAuth client errors, etc.) is a
  client/validation issue, not an internal server fault.
- Add `format_json_err_message` which surfaces the `error` field as the
  headline, summarises `details` (with a `- key=value` per entry), and
  pretty-prints the rest as JSON for unknown shapes. The frontend toast
  now reads e.g.

      Duplicate HTTP route paths detected
      - route_path=a, workspace_id=admins, http_method=post
      - route_path=a, workspace_id=starter, http_method=post

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(toast): preserve newlines and escape HTML in multi-line errors

The toast renders via `{@html processMessage(message)}`, so server-side
error bodies that span multiple lines (e.g. the duplicate-route response
from the settings endpoint) collapsed into a single line because HTML
treats consecutive whitespace (including `\n`) as a single space.

When the message contains a newline, escape HTML first (defends against
injected markup in server error bodies) and convert `\n` to `<br />` so
multi-line errors stay readable in the toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fixup: address CI review feedback

- toast.ts: escape HTML unconditionally. The previous gate on `\n` left
  single-line server error bodies unsafe under {@html}, which cubic
  flagged as P0. The path regex below only inserts a `<span>` around a
  `u/...` or `f/...` capture that can't contain HTML metacharacters, so
  escaping the whole input is the simpler and correct fix.
- error.rs: add unit tests pinning the rendered shape of
  `format_json_err_message` (error+details, error-only, truncation cap,
  non-object fallback to pretty JSON).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:13:28 +00:00
Guilhem 80f2831a84 chore(raw_apps): bump bundled ui_builder to b4f6219 (#9314) 2026-05-25 16:12:49 +00:00
GuilhemandClaude Opus 4.7 368e677419 feat(raw_apps): tab-based editor surface with split-with-preview (#9273)
* feat(raw_apps): custom tab system for source / runnable / preview

Replaces the fixed split-pane layout with a tab bar inside the editor
area. Each frontend file is a tab, each selected runnable is a tab,
and the Preview is pinned to the right (non-closable). Tabs are an
alternative discoverability surface to the sidebar — both stay
functional, but tabs make navigation viable on small screens with
the sidebar collapsed.

A "Split with Preview" toggle in the tab bar's trailing slot pairs
the active tab with the preview side-by-side for wide-screen
multitasking. The toggle hides when Preview is already the active
tab.

The UI Builder, runnable editor, and preview iframe all stay mounted
across tab switches (toggled via `display`) — no bundler restarts, no
preview state loss, no editor remounts.

- New common/tabs/DraggableTabs.svelte: reusable tab strip with
  drag-reorder (@windmill-labs/svelte-dnd-action), pinned-left/right
  slots excluded from the drag zone, hover-revealed X close, middle-
  click close, keyboard navigation (arrows / Enter / Backspace),
  and a `trailing` snippet for inline toolbar add-ons.
- raw_apps/RawAppEditor.svelte:
  - Tab state (`tabs`, `activeTabId`, `splitWithPreview`) lives in
    Windmill. Persisted in localStorage keyed by workspace + app path.
  - Sidebar file clicks (`handleSelectFile`) and runnable selection
    (`selectedRunnable` via `bind:`) are mirrored into tabs via an
    effect — the sidebar interaction is otherwise untouched.
  - Listener augmented: `setActiveDocument` backfills tabs for files
    VS Code opens by itself; `setFiles` / `runnables` updates drop
    stale tabs.
  - Bundler / inspector / rebuild toolbar moves into the tab bar's
    trailing slot — always visible regardless of active tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(raw_apps): modern tab styling + resizable split-with-preview

Two polish passes on the new tab system:

DraggableTabs styling:
- Remove the bottom border on the tab strip + the accent-coloured
  border-b-2 on the active tab. The active tab now shares the
  surface background with the content area below it, so the
  boundary visually "disappears" — modern IDE-style tabs.
- Inactive tabs sit on the darker surface-secondary tab strip and
  get a subtle right separator so they don't blur into each other.

Split-with-Preview is now a real resizable Splitpanes:
- The content area is rendered as a Splitpanes (always), with the
  source/runnable slot on the left and the preview iframe on the
  right. The user can drag the divider to adjust the ratio when
  the "Split with Preview" toggle is on.
- Iframes never remount across single↔split toggles — pane sizes
  are driven reactively from (activeTabKind, splitWithPreview),
  not by adding/removing the Splitpanes itself.
- The user's preferred split ratio is remembered while they're
  dragging and reapplied next time split is enabled.
- The inner splitter is CSS-hidden in single mode so the toggle
  button stays the single canonical way to flip layouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(raw_apps): split mode moves preview tab into the right pane

Cleaner mental model for split-with-preview. Instead of "split the
active tab + always keep the Preview tab around", the Split toggle
now physically moves the Preview tab out of the bar and into a
permanent right pane. When the user toggles split off, the Preview
tab reappears in the bar like any other tab.

- New `displayedTabs` derived: filters out the Preview tab when
  splitWithPreview is on, so the user sees only file/runnable tabs
  in the bar and a dedicated preview pane on the right.
- `toggleSplit` redirects the active tab to the most recent
  file/runnable when the user toggles split on with Preview active,
  so they don't end up staring at an empty left pane.
- Split toggle is now always visible — the user can flip both ways.
  The button label flips between "Pin preview to the right" and
  "Move preview back into a tab" to reflect what's about to happen.
- reorderTabs preserves the Preview tab in the underlying `tabs`
  array even though it's filtered out of the drag set in split mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(raw_apps): VS Code-style "Preview" header on the right pane

In split mode, the right pane now shows a small "Preview" tab-styled
header anchored at its top-left — making the layout read like a real
VS Code editor split, where each group has its own tab bar.

- Header appears only when `splitWithPreview && activeTabKind !== 'preview'`
  (i.e. when the right pane is meaningfully separate from the left's
  content). In single mode with preview active, the right pane is the
  only thing visible and the main tab bar already labels it.
- The header uses the same styling as an active tab: `bg-surface`
  on a `bg-surface-secondary` strip, h-8, text-xs, no border.
- An X button next to the label toggles split off — equivalent to
  closing the editor in VS Code's split view (preview goes back to
  living as a tab in the main bar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(raw_apps): VS Code-style symmetric tab bars per pane

Restructure the editor area so each pane is a self-contained "editor
group" with its own tab bar at the top. The Splitpanes is now the
topmost element — the divider runs floor-to-ceiling, splitting both
the tab bars and the content.

Layout (left pane = source / runnable, right pane = preview):
- Left pane top: DraggableTabs (file/runnable tabs, Preview tab when
  split is off) + Split-toggle in the trailing slot.
- Right pane top: a custom preview header — "Preview" label styled
  like an active tab on the left + the preview-affecting toolbar
  (bundler, inspector, rebuild) on the right.
- Each pane independently sized via Splitpanes; iframes + the
  runnable panel stay mounted and toggled via `display` so state
  survives every transition.

Trade-off: in single-mode with Preview active (paneA=0), the left
tab bar is hidden along with the left pane. To switch back to a
file tab the user uses the sidebar — which is exactly the
discoverability surface tabs were meant to complement, not replace.

Button placement by semantic ownership:
- Layout control (Split toggle) — left side, with the editor.
- Preview-affecting controls (bundler, inspector, rebuild) — right
  side, with the preview. No close-X on the right; the Split toggle
  on the left is the canonical way to flip layouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(raw_apps): keep tab bar visible when Preview is active in single mode

The "VS Code-style" restructure put the tab bar inside the left
Pane. When activeTabKind became 'preview' in single mode, the left
pane collapsed to width 0 and the entire tab bar disappeared with
it — leaving the user with no way to switch back to a file tab
except via the sidebar.

Move the main tab bar back above the inner Splitpanes (full width,
always visible). The preview pseudo-header stays inside the right
pane, carrying the bundler / inspector / rebuild toolbar. The
splitter only goes through the content area below the tab bar,
which is acceptable given how much friction the disappearing-tabs
edge case caused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(raw_apps): per-pane tab bars with mirrored single-mode lists

Replace the single tab bar above the inner Splitpanes with one
DraggableTabs per pane. Splitter now goes floor-to-ceiling through
tabs AND content in split mode.

In single mode both bars mirror the full tab list, so the visible
pane always carries every tab — fixes the bug where activating
Preview hid the tab strip. Clicking Preview while in split mode is
a no-op (Preview is permanently visible in the right pane).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(raw_apps): polish tab strip and sync editor font to text-xs

* feat(raw_apps): move logs overlay onto the preview pane

* refactor(splitpanes): extract pixel-aware minSize helper

* fix(raw_apps): tab hydration loads correct file; closeTab in split mode

* fix(raw_apps): lazy-mount UI Builder iframe + add dev:ui-builder script

* feat(raw_apps): default split view, blue preview tab, fix dnd ghosting

* fix(raw_apps): remove 1px splitter sliver beside preview in single view

* fix(raw_apps): tab scrollbar on hover, fix thumb height + resize staleness

* refactor(raw_apps): don't persist tab/split layout in localStorage

* refactor(raw_apps): derive pane sizes + binding setter instead of effects

* style(raw_apps): trim verbose comments

* feat(raw_apps): accept appendLogs delta from the UI Builder iframe

* fix(raw_apps): exit inspect mode on Escape

* fix(raw_apps): Escape clears lingering inspector selection after pick

* style(raw_apps): accent-selected styling for active tab, bg-surface strip

* fix(raw_apps): address PR review nits (drop debug log, timer/reorder/pane-setter, dev script restore)

* fix(raw_apps): clear inspector overlay on the preview iframe, not the source

* style(raw_apps): neutral tab look (surface-tertiary/text-emphasis selected, text-hint idle)

* chore(raw_apps): bump bundled ui_builder to 61b6fdd

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:27:25 +00:00
GuilhemandClaude Opus 4.7 2f50e8bab0 feat(ai-chat): align footer bar + DropdownV2 mode/autonomy selectors (#9308)
* feat(ai-chat): align footer bar, use DropdownV2 for mode/autonomy selectors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dropdown): add `selected` item prop rendering a trailing check

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): add small spacing between chat input and footer bar

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): always offer the 3 autonomy options in the auto-accept picker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai-chat): default autonomy mode to auto-accept on

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): use Button component for footer dropdown triggers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): use a hand icon for the auto-accept-off autonomy state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): use subtle Button variant for mode and model selectors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): tighten spacing between input and footer bar

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): reword autonomy levels as ask/auto-accept/bypass permissions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(button): add 2xs unified size with tighter padding

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): compact footer bar — 2xs buttons, AtSign context icon, short Yolo label, discreet model

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): widen the permission selector dropdown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(dropdown): group shortcut + selected check to avoid ml-auto collision

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ai-chat): cover getPersistedAutonomyMode default; clarify default comment

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:25:11 +00:00
centdix 3f219aed98 feat(ai-chat): expand chat question answers (#9310) 2026-05-25 14:52:02 +00:00
Ruben Fiszel 577a730e90 audit-log workspace-fairness cap transitions (#9306)
* feat(queue): audit-log workspace-fairness cap transitions

When the cloud per-workspace fairness mechanism adds a workspace to the
capped set or releases one, write `workspace_fairness.capped` /
`workspace_fairness.uncapped` audit-log entries to the affected workspace.
The cluster admin can review the full timeline from the `admins` workspace
audit view with `all_workspaces=true`; per-workspace owners see their own
events in their normal audit list.

Only the per-cycle refresh winner emits entries (matching where the heavy
aggregation runs), so a fleet of N workers does not produce N duplicates
per transition. The diff is computed against the value already in
`background_task_state` rather than the winner's in-memory cache, so a
freshly-restarted process winning the claim does not spuriously emit
"newly capped" entries for workspaces that were already capped before it
started.

Audit writes are best-effort: failures are logged via tracing and do not
abort the refresh cycle.

Fixes WIN-1984

* feat(queue): scope fairness audit to admins workspace + queue-metrics pane

- Write `workspace_fairness.capped` / `workspace_fairness.uncapped` to the
  `admins` workspace (was: per-affected-workspace) with the affected
  workspace_id moved to the `resource` field. Cluster admins now get the
  full timeline in one place without `all_workspaces=true`.
- Add `GET /workers/workspace_fairness_events` returning the last 100
  events. Cloud-gated (returns `[]` on non-cloud) and devops-only.
- Add a `WorkspaceFairnessEvents` Section to the Queue Metrics drawer,
  rendered only when `isCloudHosted()` is true. Shows time / event
  badge / workspace / parameters with a refresh button.

Fixes WIN-1984
2026-05-25 14:51:25 +00:00
Ruben FiszelandClaude Opus 4.7 b5a0d46695 fix(auth): filter resource/variable listings by token scope (WIN-1981) (#9302)
A token scoped to a single resource (e.g. `resources:read:u/alice/foo`)
could call `GET /api/w/{w}/resources/list_search` and receive `path` and
`value` for unrelated resources in the workspace. Route-level scope
checks only validate `domain:action`; per-resource handlers do a
`check_scopes` against the path, but the listing endpoints did not —
leaking integration credentials, API keys, and other secrets stored as
resource values to narrowly-scoped tokens.

Add `build_scope_path_predicate` to `windmill-api-auth` (mirrors
`check_scopes` semantics but parses the token's scopes once, suitable
for filtering many rows). Apply it to `list_search_resources`,
`list_resources`, `list_names` (resources) and `list_variables`
(non-secret value leak), so a scope-restricted token only ever sees the
paths it is authorized to read. Unscoped tokens and tokens whose only
scopes are `if_jobs:filter_tags:*` are unaffected.

Includes regression tests covering: unscoped, tag-filter-only,
single-resource, wildcard, wrong-domain, and write-implies-read.

Fixes WIN-1981

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:38:28 +00:00
centdix c96be0e903 refactor: move bedrock proxy handling to windmill-ai (#9309)
* refactor: move bedrock proxy handling to windmill-ai

* docs: track ai refactor follow-ups
2026-05-25 14:22:10 +00:00
centdix 1eef53170b feat: plug global chat drafts into userdraft (#9291)
* refactor: move global chat drafts to userdraft

* feat: share script and flow drafts with editors

* feat: share trigger drafts with editors

* feat: share raw app drafts with editor

* feat: share resource drafts with editors

* docs: rename global chat drafts copy

* feat: add global chat draft discard tool

* fix: resolve global chat editor draft paths

* fix: remove editor draft path resolver

* feat: track live editor drafts in userdraft

* fix: snapshot live userdraft reads

* chore: checkpoint pending global draft changes

* fix: address global draft review issues

* fix: defer raw app draft persistence

* docs: remove pr investigation docs

* fix: persist live global draft writes
2026-05-25 14:18:57 +00:00
Diego Imbert 98bd5e7f2a feat: add copy button to Path component (#9311) 2026-05-25 14:14:36 +00:00
Ruben Fiszelandrubenfiszel ff685eb2d3 chore(main): release 1.708.0 (#9304)
* chore(main): release 1.708.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-24 23:51:19 +00:00
Ruben Fiszel de2e243313 feat(queue): per-workspace fairness cap on the shared cloud worker pool (#9303)
* feat(queue): cloud-only per-workspace fairness cap on the shared worker pool

On `app.windmill.dev` the cluster runs a single default worker group, so a
single workspace flooding the queue can degrade quality of service for
everyone else. This adds an opt-in mechanism that caps any single workspace
at a configurable share of the shared worker pool when it has been
dominating cluster activity for more than a configurable window.

Detection signal counts both currently-running jobs and jobs completed in
the rolling window, so it catches workspaces hogging slots with long jobs
**and** workspaces spamming many tiny jobs (where no individual job's
started_at is old, but throughput share dominates).

Refresh is coordinated cluster-wide via a single UPDATE on
`background_task_state`: the `WHERE updated_at < now() - interval` predicate
combined with row-level locking means only one process per refresh cycle
actually runs the aggregation, regardless of fleet size. Every other
process gets the freshly written value in the same round trip via
`UNION ALL ... LIMIT 1`. Heavy aggregation rate stays at ~0.2-0.5 qps for
the whole cluster.

Pull queries are split: the existing query string and its bind shape stay
bit-identical to today, so the planner keeps using the same indexes when
fairness is off or no workspace is currently capped. A separate
`WORKER_PULL_QUERIES_FAIRNESS` adds `AND workspace_id <> ALL($2::text[])`
and is only materialized while the feature is enabled.

Hard-gated to `CLOUD_HOSTED=true` + BASE_URL host == app.windmill.dev at
three layers: frontend `cloudonly: true`, API setter rejection in
`set_global_setting_internal`, runtime check in `fairness_active`. Settings
are exposed under Jobs in the instance-settings UI; defaults are off so
the change is a no-op for self-hosted.

Two-pass pull guarantees no worker idling: if every queued job belongs to
a capped workspace, the second pass uses the unmodified pull queries.
Cap re-asserts on the next refresh.

Fixes WIN-1982

* fix(queue): address CI review findings on workspace fairness

Six fixes from the four-reviewer cross-check on #9303:

1. **Aggregation evaluation (Codex P1).** The previous `INSERT ... ON CONFLICT
   DO UPDATE WHERE updated_at < ...` had the heavy `v2_job_queue ∪
   v2_job_completed` aggregation inlined into `VALUES`, which Postgres
   evaluates for every contender to build the proposed row — losing the
   "one heavy aggregation per cycle cluster-wide" property the design
   advertises. Split into three small statements: (a) cheap claim with
   constant `VALUES`, (b) winner-only `UPDATE ... SET value = jsonb_build_object('overloaded', <agg>)`
   (Postgres only evaluates `SET` per row matching `WHERE`, so losers never
   compute the aggregation), (c) read for everyone. Heavy query now truly
   runs ~0.2-0.5 qps cluster-wide regardless of fleet size.

2. **Numeric setting wraparound (cubic P1).** `u64 as u32` and downstream
   `u32 as i32` could silently flip sign and feed `make_interval(secs => -N)`,
   making `now() - interval` a future timestamp and disabling the
   completed-jobs half of the activity signal. Clamp `duration_secs` to
   [1, 86400] and `min_total_jobs` to [0, u32::MAX] before storing.

3. **`/instance_config` bypass (cubic/Claude/Codex P2).** Bulk config endpoint
   sidestepped `set_global_setting_internal`'s gate; a self-hosted superadmin
   could persist `workspace_fairness_*` rows via the bulk path. Mirror the
   per-key check in `set_instance_config` upsert flow.

4. **DB error coerced to false (Claude P2).** `load_workspace_fairness_enabled`
   collapsed `Err(_)` to `false` and unconditionally swapped the atomic — a
   transient DB blip during notify-event propagation toggled the feature off
   cluster-wide (and triggered a `store_pull_query` rebuild precisely when load
   is highest). Now propagates the error so the atomic stays at its prior value.

5. **Refresh failure cooldown (Claude P2).** Storing `0` removed the rate
   limit entirely; every subsequent pull spawned a new refresh task. Leave
   `LAST_REFRESH_MICROS` at `now_us` (already written by the CAS) so the
   natural interval acts as the cooldown.

6. **Visibility + duplication (Pi P2).** Mark `make_pull_query_fairness` as
   `pub(crate)`. Move the duplicated `BASE_URL host == app.windmill.dev`
   parser into `windmill-common::worker::is_cloud_production_host` and share
   it between the API setter and the runtime path.

Verified locally:
- `POST /api/settings/global/workspace_fairness_enabled` → 400 (per-key gate)
- `PUT /api/settings/instance_config` with fairness key → 400 (bulk gate)
- `cargo check --workspace --features=private,enterprise,quickjs` — clean

Refs WIN-1982.

* fix(queue): second round of CI review nits on workspace fairness

Three issues raised by the Codex/Claude re-review of commit 0b38ff2:

1. Non-cloud deletes were rejected (Codex P2). The cloud gate ran before
   the Null / empty-string deletion branches in both `set_global_setting_internal`
   and the bulk `set_instance_config`. A self-hosted instance that inherited
   stale `workspace_fairness_*` rows from a cloned cloud DB couldn't clear
   them through the API — the rows stayed in `global_settings` and continued
   to show up in the YAML export. Now the gate only blocks upserts; Null /
   empty-string deletes pass through on any host.

2. Deleted numeric knobs kept stale runtime values (Codex P2). When a
   cloud admin cleared `workspace_fairness_max_percent`, `..._duration_secs`,
   or `..._min_total_jobs`, the notify-event fired but the numeric loaders
   ignored `Ok(None)` and left the previous in-memory value pinned until
   process restart. Loaders now distinguish three outcomes:
     - `Err(_)`: transient — leave atomic alone (preserves the
       previous-round fix).
     - `Ok(None)` / `Ok(Some(invalid))`: reset to the documented default.
     - `Ok(Some(valid))`: clamp and store.
   Defaults are extracted to `WORKSPACE_FAIRNESS_*_DEFAULT` constants kept
   in sync with the `AtomicU32::new(...)` initialisers in
   `windmill-common/src/worker.rs`.

3. `fairness_active` was `pub` with no cross-crate caller (Claude nit).
   Tightened to module-private.

Verified locally on this non-cloud instance:
  POST .../workspace_fairness_enabled  body=null  → 200 (delete passes)
  POST .../workspace_fairness_enabled  body=true  → 400 (set blocked)
  PUT .../instance_config              {}         → 200 (no-op passes)
  PUT .../instance_config  with fairness key      → 400 (bulk set blocked)

Skipped the partial index on `v2_job_queue WHERE running = true` that
Claude flagged as a residual nit — queue stays under 50k rows per the
operator's measurement, so the seq-scan cost (~10 ms × 0.5 qps =
~0.5% of a DB core) is well below the noise floor and the index isn't
worth the maintenance cost on job transitions.

Refs WIN-1982.
2026-05-24 23:41:18 +00:00
Ruben Fiszelandrubenfiszel 7b11ebe5f5 chore(main): release 1.707.0 (#9285)
* chore(main): release 1.707.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-22 23:35:01 +02:00
dcee8cc0d3 feat(github-app): hide cloud-only UI on self-managed + admin assignment UI (#9299)
* feat(github-app): hide cloud-only UI on self-managed + admin assignment UI

Two related UX fixes for the GitHub App self-managed (GHES) integration:

1. On self-managed instances, the per-installation Export button and the
   "Import installation from other instance" section in the workspace UI both
   hide. Both round-trip a JWT carrying only {installation_id, account_id} with
   no github_base_url, so they would produce broken cloud-style installs on a
   self-managed instance. The previous Export attempt also failed with
   "No JWT token received from server" because self-managed installs store an
   empty JWT by design.

2. New "Workspace assignments" panel in instance settings (GhesAppSettings.svelte)
   that auto-discovers installations of the configured GHES App and lets the
   super-admin assign them to specific workspaces. Workspace users without
   GitHub permissions no longer need to install the App themselves — the admin
   provisions the link from instance settings. Admin-provisioned installs show a
   "Provisioned by admin" badge in the workspace UI and can only be removed by
   the super-admin from instance settings.

Backend support is in the EE companion PR
windmill-labs/windmill-ee-private#588.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to da5189cf69a453de3855057f41be0d84e5910707

This commit updates the EE repository reference after PR #588 was merged in windmill-ee-private.

Previous ee-repo-ref: d959b83ce413ad531e9cc28e0f8199cdecb73a31

New ee-repo-ref: da5189cf69a453de3855057f41be0d84e5910707

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-22 21:01:15 +00:00
Alexander PetricandClaude Opus 4.7 daab561ec0 feat(typescript-client): add deleteS3File + optional workspace arg on S3 helpers (#9300)
* feat(typescript-client): add deleteS3File + optional workspace arg on S3 helpers

Customer-requested ergonomics for the TypeScript SDK:

- New `deleteS3File(s3object, workspace?)` wrapper around the existing
  `HelpersService.deleteS3File` (backend endpoint is already there). Saves
  callers from having to either hand-roll `denoS3LightClientSettings()` +
  AWS SDK calls, or wire up `HelpersService` directly.
- `denoS3LightClientSettings`, `loadS3File`, `loadS3FileStream`, `writeS3File`,
  and the new `deleteS3File` all gain an optional trailing `workspace?: string`
  parameter that falls back to the `WM_WORKSPACE` env var via `getWorkspace()`.
  Mirrors the calling convention customers already expect from helpers like
  `getVariable` / `runScript`.

`build.sh` and `build.jsr.sh` are updated to export `deleteS3File` from both
the NPM and JSR entry points.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: regenerate system_prompts auto-generated for new S3 helpers

`python system_prompts/generate.py` after adding deleteS3File and the
optional workspace param to the existing S3 helpers, so the agent-facing
docs (CLI skills, TS SDK prompt, script skills) reflect the new signatures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:58:50 +00:00
Ruben Fiszel e3fbc20c29 remove unused workflow 2026-05-22 20:50:49 +00:00
Ruben FiszelandClaude Opus 4.7 af48451c53 make selected resilient + snapshot args for React (#9298)
* fix(ResourceEditor): make `selected` resilient + snapshot args for React

Two issues surfaced via the React SDK (reactify wrapper re-spreads Svelte
props on every host re-render):

1. The bindable `selected` prop transiently resets to undefined on each
   re-spread, flipping `current` through undefined and unmounting the
   form (input loses focus on every keystroke). Rename the prop to
   `selectedProp` and derive `selected = selectedProp ?? effectiveWorkspace`
   so the fallback insulates the component without effects.

2. The onChange dispatch passed `current.args` (a `$state` proxy) directly,
   so React consumers diffing by reference or JSON.stringify saw the same
   value forever, and the effect only tracked the args reference (not
   nested mutations). Wrap with `$state.snapshot` to deep-track and emit
   a plain object.

The bootstrap effect is also restructured: it no longer writes `selected`
(the derived handles defaulting) and now guards on `selected in initialStates`
so workspace flips remain idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ResourceEditor): declare effectiveWorkspace before use in selected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:00:06 +00:00
Ruben Fiszel fd76053889 sdk_resource 2026-05-22 18:21:56 +00:00
Ruben Fiszel 05ef8d8e0b nit react-sdk resource editor 2026-05-22 17:18:10 +00:00
ace22910c4 fix(secret-backend): pass DB to Vault migrations + show failure details (#9292)
* [ee] fix(secret-backend): pass DB to Vault migrations + surface failure details

Companion to windmill-ee-private fix for WIN-1977. The HashiCorp Vault
migration always failed under JWT/OIDC auth because the migration
constructed VaultBackend without a DB, so every secret hit "Database
connection required for JWT authentication". Creating new secrets worked
because the runtime path passes the DB.

Frontend: when failed_count > 0, the toast and console now show the
per-secret failures (path + error, capped at 5 with "...and N more")
instead of just aggregate counts.

Fixes WIN-1977

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 14315067c083d3361512de621b12e41dbe3b017d

This commit updates the EE repository reference after PR #587 was merged in windmill-ee-private.

Previous ee-repo-ref: 390ed6c851b1915f0b492897c663f8058477680f

New ee-repo-ref: 14315067c083d3361512de621b12e41dbe3b017d

Automated by sync-ee-ref workflow.

* fix(secret-backend): escape failure fields and use <br> in migration toast

Address CI review on PR #9292:

- P1 (cubic/codex): backend-supplied workspace_id/path/error are now
  HTML-escaped before being interpolated into the migration toast,
  which renders through {@html processMessage(...)} in Toast.svelte.
  This prevents stored XSS via secret paths or backend errors that
  contain markup. '/' is intentionally left intact so the toast's
  path-highlight regex still tags workspace paths.
- P2 (pi): swap '\n' for '<br>' so multi-line failure lists actually
  break in the toast instead of collapsing to a single run-on line.
- Extend the same per-secret failure surfacing (toast + console.error)
  to the Azure Key Vault and AWS Secrets Manager migration handlers
  via a shared reportMigrationFailures() helper so all six migration
  paths report identically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-22 16:17:15 +00:00
Ruben FiszelandClaude Opus 4.7 1f2d2c1149 fix(ResourceEditor): don't reset state when selected reverts to undefined (#9295)
The bootstrap effect tracked `selected` via its early-return check, so any
time `selected` flipped back to `undefined` it would re-run and reinitialize
`states[effectiveWorkspace]` to empty — wiping user input. This happens in
the React SDK consumer: reactify re-syncs all Svelte props on every React
render, and since `selected` isn't passed through, `$props()` reverts it.

Move the `selected !== undefined` check inside the existing `untrack` so
the effect only tracks `effectiveWorkspace`. Bootstrap still runs once on
mount; subsequent `selected` flips no longer retrigger it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:38:51 +00:00
Ruben FiszelandClaude Opus 4.7 7003998a57 fix(auth): tighten token-owner fallback for unscoped tokens (WIN-1978) (#9293)
* fix(auth): reject unscoped tokens with cross-workspace forged owners (WIN-1978)

An unscoped token (workspace_id IS NULL) whose `owner` field references a
user, group, or unprefixed value that is not present in the target
workspace must not authenticate. The previous fallback in the
`u/<username>` branch granted `(is_admin=false, is_operator=true)` when
no `usr` row matched in the target workspace, letting a token holder
who could mutate the `token` table cross workspace boundaries with
operator privileges.

The `g/<groupname>` branch likewise silently accepted any group name as a
"group user", and the no-prefix branch granted operator state from
arbitrary owner strings. Both are now rejected unless the owner matches
a real user/group membership in the target workspace.

Adds an integration regression covering all three forged-owner shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: drop integration regression for auth fallback

The test added in the previous commit relies on a sqlx::query! that
requires offline-cache regeneration; removing per code-review preference
to keep this PR scoped to the auth-layer fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:30:01 +00:00
Ruben FiszelandClaude Opus 4.7 5566c7b3ff fix(flows): restore Variables and Resources in flow editor prop picker (#9290)
The design system overhaul in 888837431c accidentally dropped the
fallback condition that displayed the Variables and Resources sections
in the prop picker by default. After that commit, these sections only
appeared when the user typed `variable.` or `resource.` in their
expression, which meant they effectively disappeared from the flow
editor's prop picker for most users.

Restore the previous behavior by showing the sections when no input
match is active (the equivalent of the old `!filterActive` clause).

Fixes WIN-1976

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:27:49 +00:00
hugocasaandClaude Opus 4.7 13a2fae745 fix: flow recording teardown crash + rename package to @windmill-labs/components (#9288)
* fix: guard against null recording during FlowRecordingReplay teardown

Navigating away from a flow recording inside a workspace file-tree view
threw `TypeError: Cannot read properties of null (reading 'flow')` from
FlowGraphViewer once during the teardown tick.

Svelte 5 compiles child component props as live getters that close over
`$$props.recording.flow`. When `recording` flips to null on the parent's
navigation, an outer `{#if !recording?.flow}` doesn't stop those getters
from firing one more time as derived effects re-evaluate before the
unmount lands — so the getter dereferences null and throws.

Fix at the two layers where the deref actually happens:

- FlowRecordingReplay: use `recording?.flow` at the binding sites
  (FlowViewer + graph-snippet FlowGraphViewer) so the compiler emits an
  optional-chained getter, and guard the snippet branch with
  `{:else if recording?.flow}` so it doesn't mount when there's nothing
  to show.
- FlowGraphViewer: finish the optional chaining the rest of the file
  already used everywhere else (`flow?.value?.skip_expr`,
  `flow?.value?.cache_ttl`, `flow?.schema`). When the upstream
  binding returns undefined during teardown, the graph degrades to an
  empty frame instead of crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: rename package to @windmill-labs/components

- frontend/package.json: rename `windmill-components` → `@windmill-labs/components`
- frontend/publish.sh: drop the in-place sed rename dance; the checked-in name now matches what's published, so `npm run package && npm publish` is enough
- frontend/package-lock.json, system_prompts/auto-generated/prompts.d.ts: regenerated by `npm run package` under the new name

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:27:50 +00:00
hugocasa 486e5f947b fix(cli): wmill sync pull updates wmill-lock.yaml for raw apps (#9289) 2026-05-22 11:56:05 +00:00
4cca21ca6c chore(system_prompts): point plugin skills sync at plugins/windmill/ (#9287)
* chore(system_prompts): point plugin skills sync at plugins/windmill/

The plugin checkout's plugin folder is being renamed from
`plugins/windmill-code-plugin/` to `plugins/windmill/` to shorten the
slash-command namespace and align with the matching Cursor plugin
layout.

Paired with windmill-labs/windmill-claude-plugin#8. That PR must merge
first so the next sync run finds the new folder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(system_prompts): update plugin-dir example to plugins/windmill

Co-authored-by: centdix <centdix@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: centdix <centdix@users.noreply.github.com>
2026-05-22 11:43:34 +02:00
hugocasa e0ffea2deb feat: add wmill job rerun subcommand (#9275)
* feat: add wmill job rerun subcommand

* feat: add wmill job restart subcommand for flow restart-at-step
2026-05-22 08:31:21 +00:00
Ruben Fiszelandrubenfiszel 9b218dc405 chore(main): release 1.706.1 (#9281)
* chore(main): release 1.706.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-22 08:26:25 +00:00
Ruben Fiszel 82722449e7 fix: fork compare visibility for non-admins and stale-token superadmins (#9283)
* fix: use fork-scoped authed for fork visibility in compare_workspaces

* test: add EE end-to-end repro for fork rename visibility

* chore: restore concurrency_locks sqlx cache lost in cleanup

* test: add regression for stale-superadmin-token fork visibility bug

* chore: update sqlx cache for new test queries
2026-05-22 08:03:59 +00:00
Ruben Fiszel bd153434f4 bump git sync to 28236 2026-05-22 07:57:56 +00:00
Ruben FiszelandClaude Opus 4.7 3c3e99d1a5 refactor(cli): wmill sync git-deploy stops committing; caller owns commit+push (#9284)
Single contract for the deployment-callback path: the CLI does branch
checkout + pull, the caller (hub script in production, test in test)
does git add + commit + push. This restores the WIN-1974 invariant —
GPG setup and `git commit` run back-to-back in the same process, so
the agent's pre-warmed passphrase cache is still warm at sign time —
without needing a `--skip-commit` flag for the hub case and a default
"also-commit" for everything else. Same behavior in every call site.

Changes:
  - sync.ts: drop the gitSyncDeployPush call from pull()'s deploy path
    (both the onlyCreateBranch fast-return and the post-pull commit).
    `gitSyncDeployPush` stays exported for any caller that wants the
    same commit/push semantics — just not invoked by the CLI subcommand.
  - gitsync_promotion.test.ts: e2e test now does its own git add +
    commit + push after `wmill sync git-deploy`, mirroring what the
    hub script does in production. Same regression coverage
    (wm_deploy branch created in Case A, main untouched; main updated
    in Case B, no new wm_deploy).

CLI typecheck unchanged (two pre-existing TarAsZip errors at lines
2578/3307, present before this PR). All 743 unit tests still pass.

The accompanying hub script (option-C — CLI for branch+pull, script
for commit+push) lives at /tmp/git-sync-diff/sync-script-to-git-repo-windmill.option-C.ts.
Once published, a follow-up bumps LATEST_GIT_SYNC_SCRIPT_PATH to its id.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:54:12 +00:00
Ruben FiszelandClaude Opus 4.7 89a2f07218 fix(git-sync): bump to hub/28234 with stateless gpg.program wrapper (WIN-1974) (#9282)
* fix(git-sync): revert LATEST_GIT_SYNC_SCRIPT_PATH to hub/28230 to restore GPG-signed deploys (WIN-1974)

hub/28231 (PR #9230) is the "thin" script that hands the actual `git commit`
to the CLI's hidden `sync git-deploy`. The hub script still does the GPG
setup (import key into a fresh GNUPGHOME, dummy `gpg -bsau` to warm the
agent passphrase cache, then `git config user.signingkey` + `commit.gpgsign`
locally), but the commit no longer runs in the same `git_push` flow — it
runs minutes later inside the CLI after workspace API resolution, zip pull,
file extraction, and lockfile autofill. By the time the spawned `git commit`
asks gpg-agent for the cached passphrase, the cache state is no longer
reliable (or the spawned `gpg` ends up talking to a fresh agent), so signing
fails non-interactively with `gpg failed to sign the data`.

hub/28230 is hub/28217's in-script logic rebuilt with windmill-cli@1.703.3:
the GPG setup and the in-script `sh_run("git commit ...")` happen back-to-back
in `git_push`, so the cache is always fresh. It preserves wm_deploy / fork
branch behavior, the EE deployment-callback `main()` signature is unchanged,
and the only min-version check in EE (`is_script_meets_min_version(28103)`)
is comfortably below 28230 — so this revert is safe.

Forward fix (separate PR): publish a new thin script that, alongside the
existing GPG setup, writes a `gpg.program` wrapper using `--pinentry-mode
loopback --passphrase-file` so signing is independent of the agent's cache
state. Re-bump past 28231 then.

Fixes WIN-1974

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(git-sync): check in source-of-truth for the next hub script (gpg.program wrapper)

This is the script that will be published to hub.windmill.dev once verified
on a customer GPG-signed deploy. It replaces hub/28231's agent-cache
pre-warm (`gpg -bsau` with --passphrase) with a stateless gpg.program
wrapper + chmod-600 passphrase file. Every git-invoked gpg call goes
through the wrapper, which always uses --pinentry-mode loopback (and
--passphrase-file when a passphrase exists). Signing no longer depends on
gpg-agent having a cached passphrase by the time the CLI's `git commit`
runs — which closes WIN-1974.

Not wired in yet: LATEST_GIT_SYNC_SCRIPT_PATH stays on hub/28230 until this
script is uploaded and the new hub id is known. This file is checked in so
the diff is reviewable, future bumps have a source of truth, and a CLI
regression test can `cat` it for fixture parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): skip format/pattern validation for $var/$res/$jsonvar references in ArgInput

A resource field with a `pattern` constraint (e.g. the gpg_key.private_key
field, whose pattern enforces a `-----BEGIN PGP PRIVATE KEY BLOCK-----`
prefix) rejects values like `$var:u/me/gpg-private-key` with an "invalid
format" error in the resource editor — even though `$var:`/`$res:`/`$jsonvar:`
are placeholders the backend resolves at runtime, not the actual string
that needs to match the regex.

Bail out of all format/pattern checks (email, ipv4, ipv6, uuid, custom
pattern) when the value is one of these references. Required/numeric
bounds/array checks still apply since they're shape-level, not regex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(git-sync): bump LATEST_GIT_SYNC_SCRIPT_PATH to hub/28234 (gpg.program-wrapper fix)

hub/28234 is the forward fix for WIN-1974: replaces hub/28231's agent-cache
pre-warm (which became stale by the time the CLI's `git commit` ran) with
a stateless `gpg.program` wrapper that uses `--pinentry-mode loopback`
(and `--passphrase-file` when a passphrase exists) on every gpg invocation.
Bundled CLI is windmill-cli@1.705.0.

Verified via reproducer at /tmp/git-sync-diff/test-gpg-fix.sh: deliberately
killing gpg-agent between GPG setup and `git commit` reproduces the
customer's `gpg failed to sign the data` error verbatim under the old
flow, and the wrapper signs through it. Holds for passphrase-protected
keys, split-subkey [C]+[S] layouts, and unprotected keys.

Drops the local source-of-truth copy (`hub-scripts/`) — hub is canonical
now that 28234 is published.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(git-sync): drop verbose comment above LATEST_GIT_SYNC_SCRIPT_PATH

The git history (this PR) carries the why; the constant name + value carry
the what.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:09:02 +00:00
Aldrin JensonandRuben Fiszel 88294182c0 Reduce slim image vulnerability surface (#9279)
* Reduce slim image vulnerability surface

* chore(docker): drop apt-get upgrade -y from slim images

apt-get upgrade hurts build reproducibility (same Dockerfile + same
commit at different times produces divergent images) and trips hadolint
DL3005. The freshness it buys is dominated by simply rebuilding against
the periodically-refreshed debian:bookworm-slim base image.

The --no-install-recommends and apt-list cleanup wins are kept.

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-05-22 06:39:36 +00:00
Ruben FiszelandClaude Opus 4.7 72e2c3a6b3 fix(nsjail): gate unix-symlink test behind cfg(unix) for Windows build (#9280)
The disk_backed_refuses_preexisting_symlink_at_jail_tmp test calls
std::os::unix::fs::symlink directly, which doesn't exist on Windows
targets. Without a cfg gate, `cargo check --tests` fails on Windows
with E0433. Other symlink call sites in this crate (php_executor,
bun_executor, rust_executor, etc.) already follow this pattern.

Fixes WIN-1972

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:47:26 +00:00
Ruben Fiszelandrubenfiszel e6f80dad1c chore(main): release 1.706.0 (#9270)
* chore(main): release 1.706.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-21 16:33:02 +00:00
Ruben Fiszel b656dc6cdc feat(nsjail): optional disk-backed /tmp via instance setting (#9272)
* feat(nsjail): optional disk-backed /tmp via instance setting

* test(nsjail): unit-test tmp mount resolver and narrow visibility

* refactor(nsjail): switch tmp backing to select + conditional UI

* ui(nsjail): make tmpfs the visible default in /tmp backing select

* fix(nsjail): refuse preexisting jail_tmp to block symlink escape

* fix(nsjail): allow jail_tmp reuse on sequential nsjail calls

Codex flagged that python/ruby/rust executors invoke nsjail twice per
job_dir (install then run). The previous resolver treated any preexisting
jail_tmp as hostile and silently fell back to tmpfs on the second call,
so disk-backed mode never reached the main script run for those langs.

Use symlink_metadata().is_dir() to distinguish a real directory left by
an earlier call in the same job_dir (safe to reuse) from a symlink or
other entity (still refused, as the codebase-tar escape requires).

Also loosen the frontend visibility predicate: only hide nsjail settings
when job_isolation is explicitly 'none' or 'unshare', so deployments
that enable nsjail via DISABLE_NSJAIL=false with no DB setting can
still see the controls.
2026-05-21 15:34:49 +00:00
centdix d0ee697e8b feat: add userdraft listing primitives (#9268)
* feat: add userdraft listing primitives

* fix: cancel stale userdraft discard writes

* docs: remove global ai userdraft plan
2026-05-21 15:30:17 +00:00
Ruben FiszelandClaude Opus 4.7 0692b97c8a fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path (#9276)
* fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path

The AI proxy handler accepts an X-Resource-Path header to override the
configured workspace AI provider. When supplied, the handler loaded the
resource value from the resource table using the root DB pool with no
resources:read scope check, so any authenticated workspace user could
point X-Resource-Path at a restricted AI resource (e.g. one in a folder
they cannot read) and the proxy would use that resource's provider
credentials for the outbound AI request.

For user-supplied resource paths, now require resources:read:{path}
scope and fetch the resource through user_db.begin(&authed) so RLS
enforces the same folder/group boundary as the resource API. The RLS-
scoped $var: resolution stays in place as defense in depth. The
admin-configured workspace/instance ai_config path is unchanged.

Fixes WIN-1971

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ai): regression test for X-Resource-Path RLS enforcement

Cover all four cases:
- non-admin pointing X-Resource-Path at a restricted resource is rejected
- non-admin pointing it at a resource they own still works
- admin can point it at any resource
- workspace-configured proxy flow (no X-Resource-Path) is unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:21:27 +00:00
Ruben FiszelandClaude Opus 4.7 0bdb6a9d5d fix(debugger): add non-root user support to Dockerfile (#9277)
Mirrors the main Windmill Dockerfile pattern: creates a windmill user
(UID/GID 1000) and makes cache/work directories world-writable so the
image runs cleanly under Kubernetes securityContext.runAsNonRoot or
runAsUser: 1000 without permission errors on Bun, pip, or windmill
cache writes.

Fixes WIN-1969

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:14:29 +00:00
centdix ac26aa4e4c feat: add yolo mode for ai chat tools (#9258)
* feat: add yolo mode for ai chat tools

* nit

* fix: align chat footer controls

* feat: add ai chat autonomy modes

* feat: add autonomy mode dropdown

* fix: highlight yolo autonomy icon

* fix: auto accept flow edits

* fix: hide unsupported autonomy modes

* fix: handle auto-accept flow editor races
2026-05-21 13:25:25 +00:00
hugocasaandClaude Opus 4.7 1ba8ed8abd feat(cli): add wmill init prompts and custom override slot (#9266)
* feat(cli): add `wmill init prompts` and custom override slot

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cli): replace init prompts with refresh prompts + AGENTS.md/AGENTS.cli.md split

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): dedupe claude skills via @-includes and add prompts freshness check

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cli): drop migration-choice flags from `refresh prompts`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cli): add 'Running and previewing local changes' section to AGENTS.cli.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): write full skill content to .claude/, drop @-include wrapper

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): reconcile CLAUDE.md the same way as AGENTS.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): address PR review nits — argv parsing, lazy import, comment detection, error propagation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:44:15 +00:00
d29a5612fc fix(indexer): tell admins when ingress routes search to wrong pod (#9274)
* [ee] fix(indexer): tell admins when ingress routes search to wrong pod

When the IndexReader is absent on the pod handling a search request but
another pod is actively holding the indexer lock, the EE handler now
returns a tailored error pointing at the ingress/load-balancer
configuration instead of the generic "indexer not running" message.

The indexer status endpoint reads the DB lock so it reports "running"
from any pod, but search endpoints need the in-memory IndexReader that
only exists on the lock holder. In multi-replica deployments this looks
like the indexer is healthy but every search 404s.

Companion: windmill-labs/windmill-ee-private#TBD

Fixes WIN-1968.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817

This commit updates the EE repository reference after PR #586 was merged in windmill-ee-private.

Previous ee-repo-ref: 7dd43d1850813071cc18ba49ba090583e7321f4b

New ee-repo-ref: eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-21 10:00:06 +00:00
Ruben FiszelandClaude Opus 4.7 1169371d48 feat: add UV_PYTHON_INSTALL_MIRROR env and instance setting (#9271)
* feat: add UV_PYTHON_INSTALL_MIRROR env and instance setting

Allows operators to point `uv python install` at a private mirror of the
python-build-standalone releases. Configurable via the
`UV_PYTHON_INSTALL_MIRROR` env var or the `uv_python_install_mirror`
instance setting, with the env var as the boot fallback and the instance
setting taking precedence at reload.

Fixes WIN-1966

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: hoist uv_python_install_mirror binding above sandboxing branch

The non-sandboxed uv pip install branch referenced a binding that was
only declared inside the sandboxed branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: neutral placeholder for uv_python_install_mirror

The previous placeholder was the default public URL the setting is meant
to redirect away from. A neutral example mirror URL is clearer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:24:50 +00:00
Ruben Fiszel 34986ee9b7 oom_adj nit 2026-05-21 04:29:34 +00:00
Diego Imbertandclaude[bot] 28c8b5c60f feat: CLI datatable serve / psql (#9267)
* feat(cli): add datatable list and run commands

* feat(cli): render datatable query results as a table

* feat(cli): serve datatables as a postgres-wire endpoint

* feat(cli): add 'datatable psql' to launch psql against the proxy

* feat(cli): route datatable serve by client-supplied database name

* override database list + password option

* fix: support extended queries in datatable serve

* fix: correct cloud size threshold log and parse CLI descriptions with parens/trailing comma

* refactor: extract raw_output envelope encoding into pg_raw_output module

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-05-20 16:56:50 +00:00
hugocasa 2921f26434 chore: add playwright mcp for frontend verification (#9269) 2026-05-20 16:38:08 +00:00
Ruben Fiszelandrubenfiszel 9cb34397eb chore(main): release 1.705.0 (#9229)
* chore(main): release 1.705.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-20 16:28:29 +00:00
Diego ImbertandClaude Opus 4.7 740a35bf7b fix(flows): flag noLogs jobs and lazily resolve them in log panel (#9099)
* fix(flows): flag noLogs jobs and lazily resolve them in log panel

* fix appending to flag

* fix: preserve WM_LOGS_SKIPPED sentinel on SSE/replay completion

pickMoreCompleteLogs resolved both sentinel and undefined to '', so the
SSE completion event (whose job field is fetched .without_logs()) would
clobber the sentinel placed by flagSkippedLogs. The module log panel
then saw '' instead of the sentinel, defeating the lazy-resolve path.

Also wire onLogsResolved on the OutputPickerInner inline LogViewer so a
lazy resolve writes back to flowStateStore.previewLogs, matching
ModulePreviewResultViewer and avoiding repeated fetches on remount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:15:11 +00:00
hugocasa 2db1c0a1fc fix: early return should consider failure_module result (#9241) 2026-05-20 15:58:05 +00:00
centdix 289549a048 refactor: move google ai proxy handling to windmill-ai (#9260)
* refactor: add ai proxy execution mode

* refactor: move google ai proxy handling

* refactor: share google ai request building
2026-05-20 15:24:07 +00:00
0f7dd86e5c feat: persistent in-editor drafts via UserDraft (#9121)
* refactor(frontend): remove localStorage-backed autosave drafts

Strip the per-editor localStorage autosave for flows, apps and raw apps,
along with the associated restore toasts and diff actions, so we can
replace them with a unified UserDraft service in a follow-up. The
backend DraftService (DB-backed drafts) is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): add UserDraft service for per-workspace local drafts

Introduces UserDraft, a key-value store keyed by
`{workspace}/{itemKind}/{path}` and backed by localStorage. Supports
save/get/remove plus a reactive use() handle so multiple component
instances observing the same draft stay in sync via a shared $state
loaded through useLocalStorageValue. Designed to host drafts for
scripts, flows, apps, raw apps, resources, variables, and all trigger
kinds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tests

* nit schedule_ prefix

* feat(frontend): persist deep mutations in useLocalStorageValue

Track the serialized value alongside the $state and add an $effect that
deep-reads it (via readFieldsRecursively). When a deep mutation produces
a serialization that differs from the last persisted blob, write it to
localStorage. The setter keeps writing synchronously so callers reading
localStorage right after assignment still see the new value; the effect
no-ops on those because lastSerialized was already updated by the setter.
Undefined values are persisted as a removal.

UserDraft no longer needs its own removeItem workarounds for undefined
values — useLocalStorageValue handles that uniformly now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): add defaultValue + empty-path handling to UserDraft

UserDraft.use() accepts an opts.defaultValue used when no localStorage
entry exists yet. It is not persisted on first read — only an actual
mutation writes through.

Empty paths (new items) bypass localStorage entirely. The entry still
lives in the in-memory Map so multiple components on the same /add page
share state, but save/get/remove/use never read or write localStorage
with an empty path. Once the item is saved and the route navigates to
its new URL, a fresh use() on the non-empty path takes over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire script editor to UserDraft

The script editor's top-level state now lives in UserDraft.use(), keyed
on the route's path (page.params.path on /scripts/edit, '' on /scripts/add).
Deep edits inside ScriptBuilder persist automatically; deploy and draft
restore now call UserDraft.remove to clear the local autosave alongside
the backend draft.

Replaces the URL-hash autosave that ScriptBuilder used to write via
replaceStateFn — that prop is now gone, the encodeScriptState debounce
is gone, and Triggers no longer takes a saveSessionDraft callback.
Viewing a specific historical hash (?hash=...) is kept draft-free by
passing '' as the path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire flow editor to UserDraft

flows/add and flows/edit drive the flow value through a StateStore
adapter backed by UserDraft.use, so every edit auto-persists at
userdraft/w/{ws}/flow/{path} without touching FlowBuilder's internal
.val convention. On returning visits the local autosave wins and a
toast offers a diff against the latest backend draft/deployed version;
on a fresh visit the backend value is written into the handle. Deploy,
save-as-draft rename, restore-draft and restore-deployed each call
UserDraft.remove on the route path so the local autosave doesn't
outlive the action.

Adds UserDraft.has() for "is there already a local draft?" detection
in the load path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire app editor to UserDraft

AppEditor registers a UserDraft.use<App> handle for its current path
(empty path for /apps/add stays in-memory) and a single $effect
deep-tracks the internal stateApp and forwards every mutation to the
handle. useLocalStorageValue's lastSerialized check then dedupes the
actual localStorage writes per tick, so even fast drag/resize loops
only persist when the JSON output really changes.

/apps/edit overlays a local autosave from UserDraft.get on top of the
backend value when one exists, with the existing "Discard / Show diff"
toast wired to UserDraft.remove. Deploy, save-as-draft, restore-draft
and restore-deployed all call UserDraft.remove on the relevant path,
including the JSON editor save paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire raw app editor to UserDraft

/apps_raw/edit owns the canonical raw-app state (files, runnables,
data, summary) in four $state vars; a single $effect deep-tracks them
and forwards the bundle to a UserDraft.use<RawAppDraft> handle so each
mutation tick persists at userdraft/w/{ws}/raw_app/{path} (deduped by
useLocalStorageValue's serialized check). On load the route overlays
the local autosave on top of backend.draft/deployed and offers a
"Discard / Show diff" toast when they diverge; matching local entries
are silently dropped. Deploy, save-as-draft rename, restore-draft and
restore-deployed each call UserDraft.remove on the route path.

/apps_raw/add keeps the same shape (UserDraft.use with empty path)
so the draft is in-memory only and we drop it explicitly when the
initial save creates the real path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire resource editor to UserDraft

ResourceEditor registers a UserDraft.use<ResourceState> handle keyed
on the initialPath (empty for new resources, in-memory only). A
$effect deep-tracks the current workspace's edit state and forwards
mutations to the handle; on bootstrap and lazy backend-fetch the
local autosave wins over the backend value when they diverge. After
a successful save() we call UserDraft.remove so the local autosave
doesn't outlive the deploy. Cross-workspace deploys always start from
the live backend value rather than the local draft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire variable editor to UserDraft

VariableEditor persists the current workspace's edit state via
UserDraft.save on every mutation, keyed on editPath ('' for new
variables → in-memory only). Backend fetches now overlay a matching
local autosave when one exists, and initNew() rehydrates from the
in-memory empty-path entry so opening a fresh "Add variable" drawer
keeps any unsaved work from the previous open. After a successful
save we drop the corresponding entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor external changes sync

* fix(frontend): don't UserDraft.remove flows while route is still mounted

The /flows/add and /flows/edit routes drive FlowBuilder from a flowStore
whose getter reads flowHandle.draft directly. Calling UserDraft.remove
synchronously before goto() therefore wiped the in-memory entry, made
flowStore.val collapse to emptyFlow(), and tripped
UnsavedConfirmationModal against the just-saved value — even though the
deploy/save-draft itself succeeded.

Drop those explicit removes in onSaveInitial, /add onDeploy, and
/edit onDeploy. The empty-path entry self-cleans on unmount via
onDestroy ref counting; for the non-empty edit path the next visit's
load-time diff will silently overwrite localStorage when the local
autosave matches the deployed value. Restore-draft/restore-deployed
keep their explicit remove because they navigate to the same route
(no modal) and loadFlow immediately rehydrates the handle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "fix(frontend): don't UserDraft.remove flows while route is still mounted"

This reverts commit 079ebef72b.

* Only remove from localStorage

* feat(frontend): saveInitialValue option on useLocalStorageValue

The first time a value flows into a UserDraft.use() handle — typically
the editor route loading the backend value via flowHandle.draft =
backendFlow — is the baseline, not a user edit. Persisting it on the
spot puts a copy of the backend into localStorage on every page open
and produces spurious "local autosave" toasts on next visit when the
serialization round-trips differently.

useLocalStorageValue now takes options.saveInitialValue (default true,
backward compatible). When false, the first time the serialised form
of the state changes — via the setter or via a deep mutation — the
lastSerialized cache is updated but localStorage is not touched. Every
write after that persists normally. UserDraft.use() passes false.

Tests updated to reflect the new contract (first write is the
baseline) and a regression test added for the second-write-persists
behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): persist full multi-workspace bundle for resources/variables

ResourceEditor and VariableEditor can stage edits for several target
workspaces in a single drawer session (see deployTo / states[ws] map).
The previous UserDraft wiring only persisted states[$workspaceStore] —
the user's session workspace — so any edit made under a different
target workspace tab disappeared on refresh.

Persist the entire `states: Record<wsId, State>` bundle as the draft
value instead. On lazy-fetch we pick the local state for that ws if
present and divergent from the backend; on bootstrap for new
resources/variables we restore states for every workspace the user
had staged. The localStorage key still lives under the user's session
workspace via UserDraft, but its contents now cover all target
workspaces from that session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): bake parent_hash into the initial script load

loadScript() assigned the backend value to scriptHandle.draft and then
deep-mutated parent_hash on the next line. Under
useLocalStorageValue's saveInitialValue=false contract only the very
first write is the baseline — the parent_hash mutation right after
counted as a second write and was persisted to localStorage, so
opening an existing script would silently write a draft entry even
though the user hadn't touched anything.

Combine `parent_hash` (and the topHash override) into a single
bakedBaseline so each branch of loadScript performs exactly one
assignment to scriptHandle.draft. Mirrored across the local-autosave
branch's discard callbacks too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire SqsTrigger editor to UserDraft

Persist the trigger's getSaveCfg() output to
userdraft/w/{ws}/schedule_sqs/{path} on every edit, overlay any
existing local autosave on top of the backend value when openEdit
loads the trigger, and clear the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire KafkaTrigger editor to UserDraft

Same pattern as the Sqs trigger: persist getSaveCfg() on every edit,
overlay any local autosave on top of the backend value when openEdit
loads the trigger, drop the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire NatsTrigger editor to UserDraft

Same pattern as the Kafka trigger: persist getSaveCfg() on every edit,
overlay any local autosave on top of the backend value when openEdit
loads the trigger (with initialConfig/originalConfig snapshotted from
backend first so hasChanged correctly reports the overlay as unsaved),
drop the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire MqttTrigger editor to UserDraft

Same pattern: persist getSaveCfg() on edits, overlay local autosave
in openEdit (with initialConfig/originalConfig snapshotted from
backend first), drop the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire GcpTrigger editor to UserDraft

Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire AzureTrigger editor to UserDraft

Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire WebsocketTrigger editor to UserDraft

Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire PostgresTrigger editor to UserDraft

Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire EmailTrigger editor to UserDraft

Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire HTTP RouteEditor to UserDraft

Same pattern as the other triggers, keyed on schedule_http.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire ScheduleEditor to UserDraft

Same pattern, keyed on schedule_schedule. ScheduleEditor doesn't track
an originalConfig (its saveDisabled doesn't compare against a baseline)
so ordering is simpler — initialConfig snapshotted from backend, local
autosave overlaid after.

This completes UserDraft wiring across all 11 trigger editors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(frontend): rename schedule_* UserDraft kinds to trigger_*

The schedule_ prefix grouped all the trigger editors under what looked
like a "scheduler" namespace; trigger_ is what these actually are
(triggers — including the cron-style schedule). Mechanical rename
across UserDraftItemKind, every trigger editor's UserDraft.save/get/
remove calls, and the one test that asserted on the localStorage key.

Behaviour-only impact: existing localStorage keys under
userdraft/w/{ws}/schedule_{kind}/{path} from older builds will be
ignored on next open (no schema migration). Users will lose any
unsaved trigger drafts persisted before this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(frontend): wrap UserDraft localStorage payload as { value }

localStorage entries now look like {"value": <draft>} instead of just
<draft>. The wrapping is invisible at the API boundary — UserDraft.use,
.save, .get, .remove all still operate on the unwrapped draft value —
but it leaves room to add metadata (timestamps, originating user,
schema version, ...) later without breaking existing entries.

Internals:
- StoredDraft<V> = { value: V } is what we serialise to localStorage
  and what useLocalStorageValue's $state holds.
- wrap()/unwrap() helpers gate the boundary; the handle returned by
  use() unwraps on get and wraps on set.
- readPersisted() defensively drops entries whose payload isn't a
  { value: ... } object, so pre-migration drafts written by earlier
  commits on this branch are simply ignored (has() returns false,
  get() returns undefined) rather than confusingly surfacing as
  undefined-shaped drafts.

Test data switched from { value: X } (which collides confusingly with
the wrapper shape) to plain primitives / objects, plus a regression
test for the pre-migration ignore behaviour. 28 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(backend): expose freshness for UserDraft staleness check

Variable
- Add `edited_at TIMESTAMPTZ NOT NULL DEFAULT now()` + `edited_by VARCHAR(50)` to the `variable` table (parity with `resource`); set them on INSERT and on every UPDATE.
- Surface them on `ListableVariable` so `getVariable` / `listVariable` return them.

DB drafts (script, flow, app/raw_app)
- The `*WithDraft` endpoints now also return `draft.created_at` as `draft_created_at`. The draft value alone wasn't enough to tell whether a teammate (or another tab) had pushed a fresh draft while local autosave was in flight; the new field is the staleness signal.
- Wired in `get_script_by_path_w_draft` (`ScriptWDraft.draft_created_at`, including the `prefetch_cached` forwarding), `get_flow_by_path_w_draft` (`FlowWDraft.draft_created_at`), and `get_app_w_draft` (`AppWithLastVersionAndDraft.draft_created_at`). OpenAPI updated to match.

The frontend will read these in a follow-up to implement the local-draft staleness check; this commit only widens the API surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): track remote rev metadata on UserDraft entries

Extends StoredDraft<V> with two optional rev fields used by the
forthcoming staleness modal:

- remoteRev — the deployed version's id/hash/timestamp at the moment
  the local draft was created. Compared against the latest deployed
  rev on reload.
- remoteDraftRev — the DB-draft created_at at the moment the local
  draft was created. Only meaningful for kinds that have a DB draft
  (script, flow, app, raw_app). Checked first so a teammate's draft
  push is detected before the "deployed version moved" case.

API additions on the handle returned by UserDraft.use():

- handle.meta — read the rev metadata currently stored.
- handle.setDraftAndMeta(value, meta) — atomic write of value + meta in
  a single state.val assignment. Editor routes use this on load so the
  baseline rev rides along with the value without consuming the
  saveInitialValue=false dedup slot twice.
- handle.setMeta(meta) — update just the rev metadata after the user
  picks "Keep current draft" in the staleness modal.
- handle.draft = X — unchanged surface; now preserves existing rev
  metadata across user edits.

Plus UserDraft.getMeta() and UserDraft.save() preserves any persisted
rev metadata when called without a live handle.

7 new tests cover the metadata surface; all 35 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): staleness modal for the script editor's local autosave

Replace the script editor's toast-based "Discard / Show diff" pattern
with a dedicated modal that surfaces *why* the local autosave is out
of date: a new DB draft on the server, or a new deployed version.

Adds `checkStaleness` (UserDraftMeta vs current backend revs, draft-rev
priority) and a `setMeta({ force: true })` mode so the "Keep current
draft" acknowledgement persists even when it happens to be the
entry's first state mutation — under `saveInitialValue: false` an
ack-only setMeta would otherwise be skipped and the modal would
re-fire on next mount.

The modal lives at LocalDraftStaleModal.svelte; the script editor
wires it as a template for the remaining editors. Other editors
(flows, apps, raw_apps, resources, variables, triggers) still use
the previous toast pattern and will be migrated in follow-up
commits.

* feat(frontend): staleness modal for flow, app, and raw-app editors

Migrates the flow, app, and raw_app editor routes to the same
`LocalDraftStaleModal` flow already used by scripts: compare the
recorded meta against the current `version` / `versions[last]` and
`draft_created_at`; on mismatch, surface the choice in a modal.

Adds `UserDraft.saveMeta` for routes that don't hold a live handle
(the app editor reads via `UserDraft.get` and the handle lives in
the child `AppEditor` component). It writes meta directly to
localStorage and tolerates the no-entry case.

* feat(frontend): migrate legacy localStorage autosave entries

Apps and flows used to autosave under un-scoped keys (`flow`/`flow-{path}`,
`app`/`app-{path}`, `rawapp`/`rawapp-{path}`) with a base64-encoded
state envelope. This adds a one-off migration that rewrites surviving
legacy entries under the workspace-scoped `userdraft/w/{ws}/{kind}/{path}`
keys with the new `{ value }` wrapper, transforms the payload where the
shape differs (drops the flow view-state envelope, defaults the new
raw-app `summary` field), and drops the source key.

The migration lives in its own file (`userDraftLegacyMigration.ts`)
so the new UserDraft service stays free of legacy decoders. Idempotent
via a `userdraft/legacy_migrated_v1` sentinel; runs from the logged-in
root layout once a workspace is known. Defensive shape checks avoid
clobbering co-resident apps that happen to use the same key prefixes.

* nit remove comments

* refactor(frontend): per-workspace UserDraft handles in Resource/Variable editors

Earlier commits in this PR wired the resource and variable editors to a
single multi-workspace bundle stored under the user's session workspace
key — which mixed workspaces in one localStorage entry and required a
custom multi-key fix-up pass to persist edits for other workspaces.

Reset both editors to their pre-PR shape and apply the minimal change:
the per-workspace `Record<string, ResourceState>` (resp. `VariableState`)
becomes `Record<string, UserDraftHandle<…>>`, with one handle per
workspace created via `UserDraft.use(…, { workspace: ws })`. The handle
keys its own localStorage entry under that workspace, so cross-workspace
edits stay cleanly separated and reactivity flows through the handle's
`draft` accessor — `bind:` on form fields just works.

Adds `manualRelease: true` + `handle.release()` to `UserDraft.use` so
the editors can register handles lazily inside an effect (Svelte 5
forbids `onDestroy` outside component init). The editors register a
single top-level `onDestroy` that releases every collected handle.

After a successful save, the per-workspace autosave is cleared via
`UserDraft.remove(itemKind, path, { workspace })`.

* refactor(frontend): seed per-workspace handles via UserDraft.use defaultValue

ensureHandle was doing a post-hoc `if (h.draft === undefined) h.draft = baseline`,
which relies on the saveInitialValue=false skip to swallow that seeding
write. Hand the baseline to `UserDraft.use({ defaultValue })` instead —
useLocalStorageValue uses it as the initial $state value when localStorage
is empty, so lastSerialized is correct out of the gate and no setter call
is needed.

* feat(frontend): persist empty-path drafts across reloads

Empty paths used to be in-memory only (via the `isLocalOnly` short-circuit)
because we worried about collisions between concurrent /add tabs. The user
asked for the trade-off to flip: a /flows/add or /scripts/add reload should
restore the user's work, while explicitly clicking "+ Flow / + Script / …"
should always open a clean editor.

- Drop `isLocalOnly` from UserDraft so empty-path entries persist under
  `userdraft/w/{ws}/{kind}/` like any other path. The existing per-kind
  refcounting and saveInitialValue=false behavior already handle them
  correctly — the change is just lifting the bypass.
- Each /add page now calls `UserDraft.remove(kind, '')` synchronously
  when `?nodraft=true` is present in the URL, before the handle is
  created.
- The two "+" entry points that lacked the `?nodraft=true` flag
  (CreateActionsScript's plain `<a href>` and CreateActionsFlow's
  YAML/JSON import paths) now include it, so every fresh-start path goes
  through the wipe.
- Tests updated: the "empty path (in-memory only)" block becomes
  "empty path (persists across reloads)" and asserts the new behavior.

* refactor(frontend): drop legacy-migration shape guard

We assume Windmill is the only app on the origin, so the
isPlausibleLegacyValue per-kind shape check was just dead weight.
Keep the cheap "decoded is an object" guard for malformed payloads.

* docs(frontend): refresh stale "in-memory only" comments around empty paths

Empty-path UserDraft entries persist now. Drop the leftover "in-memory
only" comments on the /add pages' handle creation, and rewrite the
EditorHeader save-initial-draft comments to describe why the UserDraft.remove
call is still needed: the draft was promoted to a real path on the
backend, so the prior-path autosave must not shadow a future "+ App" /
"+ Flow" / … visit.

* fix(frontend): strip ?nodraft=true from /add URLs synchronously

The previous cleanup ran in afterNavigate, which (a) fires asynchronously
— a quick reload between mount and the callback would re-wipe the
freshly-started draft — and (b) did `url.search = ''`, nuking sibling
params like ?template, ?hub, and ?wac.

Move the URL cleanup to the same synchronous block that calls
UserDraft.remove on nodraft, using `window.history.replaceState` so it
lands before paint. Only the `nodraft` key is removed — other params
survive.

* feat(frontend): toast when editor opens on a local autosave

When a route loads its local autosave (differs from backend, no
staleness alarm), surface "Restored from local storage" with up to
two reset actions:
- "Reset to saved draft": drop the autosave, reapply the backend DB
  draft. Only shown when the backend has a DB draft.
- "Reset to deployed": drop the autosave, delete the DB draft on the
  backend (if any), reload from the deployed version. Only shown when
  the item has a deployed version.

The toast title + label wording + per-state inclusion live in a
single helper (`$lib/userDraftToast`). Each editor passes its own
reset callbacks since the side effects differ per route (handle vs
UserDraft.get/save, redraw counters, loadXxx helpers).

Wired to scripts/edit, flows/edit, apps/edit, apps_raw/edit. Resource
and variable editors don't have DB drafts and use per-workspace
handles — a follow-up will tailor a single-action version.

* feat(frontend): load URL-encoded scripts on /scripts/add

The "Fork" action on run/[...run] and several workspace-settings
helper-script templates base64-JSON-encode a NewScript into the URL
hash on `/scripts/add#...`. Until now /scripts/add silently dropped
that payload — both call sites landed on a blank editor.

Decode `page.url.hash` at module top, and if it parses to an object,
apply it as `scriptHandle.draft` and surface "Loaded from URL". The
URL value wins over local autosave, ?template, ?hub, and YAML imports
because the hash represents an explicit "open this script" intent.

Parsing is inlined rather than reusing `decodeState` so an unrelated
hash (e.g. a future route anchor) doesn't fire its default "Impossible
to parse state" error toast.

* feat(frontend): strip URL hash from /scripts/add after consumption

The URL-encoded script is a one-shot seed (Fork preview, workspace
handler templates, hub publish) — keeping the hash in the bar after
loading meant a reload would re-apply the original payload and wipe
whatever the user edited since landing.

After applying `urlScript` and firing the "Loaded from URL" toast,
clear `location.hash` via `window.history.replaceState`. The user's
edits then flow into the normal autosave path (UserDraft empty-path
entry), and a reload restores those edits instead of the seed.

* feat(frontend): load URL-encoded scripts on /scripts/edit + consume-once

Mirror the URL-hash seed mechanism from /scripts/add to /scripts/edit
for parity: decode the base64-JSON-encoded NewScript payload from the
URL hash, apply it over the bakedBaseline as the editor's initial
state, send "Loaded from URL", and strip the hash immediately via
window.history.replaceState so a reload restores the user's autosave
rather than re-injecting the seed.

The seed wins over local autosave + backend draft + deployed —
UserDraft.remove(script, draftPath) drops the stale autosave on disk
before setDraftAndMeta writes the seeded value, so the user's
subsequent edits will overwrite cleanly.

Skipped when ?hash= is in the URL (historical-version view, which is
read-only relative to drafts) and when the hash fragment isn't a
parseable encoded payload.

No callers build /scripts/edit#<encoded> URLs today — this lands the
mechanism for future symmetry with /scripts/add.

* fix(frontend): "Reset to deployed" loop on Restored-from-local toast

UserDraft.remove only clears localStorage — the entry's reactive cell
stays alive as long as some component holds a handle. The toast
callback was relying on remove+loadXxx to reset state, but loadXxx
then read the *in-memory* autosave through the still-alive entry,
matched it against the now-deployed reference, and re-fired the same
toast. Forever.

Drop the in-memory state explicitly before the load:
- scripts/flows/apps_raw (route-level handle): `handle.setDraftAndMeta(undefined, {})`
- apps (handle lives in the AppEditor child): set `app = undefined`
  to unmount AppEditor — its onDestroy releases the handle and the
  entry's refcount drops to 0, destroying the entry.

ScriptBuilder / FlowBuilder / RawAppEditor briefly unmount while the
reload fetches; the flash is the user-visible "loading" cue.

* fix(backend): convert draft.created_at to TIMESTAMPTZ

The new `*WithDraft` endpoints surface `draft.created_at` as
`Option<chrono::DateTime<Utc>>` for the frontend's staleness check,
which requires `TIMESTAMPTZ`. The column was originally created as
plain `TIMESTAMP`, so SQLx fails to deserialize any row that has a
non-null draft and the handler returns HTTP 400 instead of 200 —
caught by `test_draft_endpoints` in the integration tests.

Migrate the column to `TIMESTAMPTZ`, interpreting existing values as
UTC (matching `now()`'s behaviour on a UTC server). No compile-time
sqlx queries reference the column, so the offline cache stays valid.

* fix(frontend): settings drawer auto-opening on /scripts/edit

ScriptBuilder's metadataOpen flag fires when `initialPath == ''` (the
heuristic for "new script, expected on /scripts/add"). The route's
`let initialPath = $state('')` left it empty until applyBaseline ran
later inside loadScript.

Pre-PR, the editor was gated on a route-level `script` $state that
started undefined, so ScriptBuilder didn't mount until loadScript's
synchronous block set both `script` and `initialPath` in the same
tick. With UserDraft.use reading localStorage synchronously, the gate
(`scriptHandle.draft`) is satisfied at mount time and ScriptBuilder
mounts with the still-empty initialPath, popping the drawer open.

Seed initialPath from page.params.path synchronously so ScriptBuilder
sees the path on its first render. Falls back to '' for the historical
`?hash=` view to preserve the existing behaviour there.

* fix(backend): refresh draft.created_at on every upsert

The draft upsert was `ON CONFLICT (...) DO UPDATE SET value = EXCLUDED.value`,
so subsequent draft writes left `created_at` frozen at the first INSERT.
The frontend's UserDraft staleness check reads that timestamp as
`remoteDraftRev`; with it frozen, an updated remote draft looked
identical to the originally-baselined one and the "newer draft was
saved on the server" modal never fired.

Touch `created_at` on conflict too. The column's semantic widens from
"first write time" to "last write time", which is what every reader of
the field actually wants — the staleness signal is the only consumer.

SQLx offline cache regenerated to match the new query text.

* fix(frontend): persist trigger drafts in script-editor autosave

The triggers in ScriptBuilder live in a dedicated `triggersState`
$state, separate from the `script` object that the UserDraft handle
deep-tracks. Pre-PR the per-builder localStorage autosave bridged the
two by snapshotting `triggersState.getDraftTriggersSnapshot()` into
the payload on every write — that bridge was dropped when we removed
the per-builder autosave in favour of UserDraft.

Add an $effect that deep-reads triggersState and mirrors the snapshot
back into `script.draft_triggers`. The UserDraft handle (already
deep-tracking `script`) then persists the trigger drafts as part of
the script autosave, restoring the prior behaviour.

* feat(frontend): debounce option on useLocalStorageValue + 500 ms in UserDraft.use

Adds `debounce: number` to `useLocalStorageValue`'s options. When set,
repeated mutations within the window collapse into a single
localStorage write fired by a plain `setTimeout`. The in-memory
`$state` is updated on every change so readers of `.val` always see
the latest value; only the persistence side-effect is deferred.

No `onDestroy` flush — the timer is independent of the Svelte
lifecycle, so SPA route teardown doesn't drop the pending write
(the callback still fires later as long as the JS context is alive).
A hard browser tab close within the window does drop it; that's an
acceptable trade-off vs the complexity of `beforeunload` listeners
and the leak/refcount issues they create alongside `useLocalStorageValue`'s
keyed instances.

`UserDraft.use` opts in with `debounce: 500` so a typing storm in the
script/flow/app editor produces one localStorage write per 500 ms
instead of one per keystroke.

Tests switch to `vi.useFakeTimers()` and a `flushPersist()` helper to
keep the synchronous `expect(localStorage…)` assertions working. New
test verifies the coalescing behaviour end-to-end.

* fix(frontend): tighten legacy-migration key matching

The legacy migration was consuming any localStorage key starting with
`app-`, `flow-`, or `rawapp-`, with no constraint on what followed and
no shape check on the decoded payload. Two failure modes called out
in review:

1. A future feature (or third-party extension) picking a name like
   `app-recent` would silently lose data on first migration run.
2. A stray key that happened to base64-decode to valid JSON but
   wasn't a real legacy draft would still get promoted to the new
   format, surfacing later as a phantom "Restored from local storage"
   toast on the next edit.

Two guards:

- `LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/`: after a `<prefix>-` match,
  the remainder must look like a Windmill item path (`u/owner/name`
  or `f/folder/name`, possibly with deeper segments). Bare-prefix
  empty-path entries (`app` / `flow` / `rawapp` for `/add` autosaves)
  still match the exact branch and don't go through the shape gate.
- `isPlausibleLegacyValue`: after decode, require the payload to
  carry the field the legacy writers actually produced
  (`flow.flow` for flows, any of `summary|value|policy|path` for
  apps, any of `files|runnables|data` for raw apps).

Both are belt-and-suspenders: nothing else currently uses these key
prefixes, but enforcing the shape locally keeps the migration safe
against future namespace collisions.

* fix(backend): drop AT TIME ZONE 'UTC' from draft.created_at migration

The original migration forced `USING created_at AT TIME ZONE 'UTC'`,
which tags every existing wall-clock value as UTC. That matches the
common case (Postgres on a UTC server, which the Docker image and most
managed offerings default to), but on a non-UTC operator's deployment
it shifts all pre-migration timestamps by the server's tz offset.

Drop the USING clause. Postgres's default `TIMESTAMP -> TIMESTAMPTZ`
cast reinterprets each existing value in the session's current
timezone — which is the same timezone under which the original
`INSERT ... DEFAULT now()` values were truncated to TIMESTAMP, so
the conversion correctly recovers the original instant regardless of
the operator's timezone. Same semantics on UTC servers, correct
semantics on non-UTC servers.

Down migration updated symmetrically.

* docs(frontend): clarify staleness modal copy

The four route-level editors (scripts/flows/apps/apps_raw) keep the
user's local draft visible behind the modal so they can glance at it
before choosing. The old body text described the situation (server
has moved on, local autosave is behind) but didn't say what's
actually on screen or how each action maps to it.

New body leads with "The editor is showing your local autosave" and
spells out each action: "Load latest replaces what's on screen; Keep
current leaves it alone." Same copy for both `cause = 'draft'` and
`cause = 'version'`, branching only on what the user is "behind"
relative to.

* refactor(frontend): drop dead updateDraftCallback from Triggers constructor

None of the eight `new Triggers(...)` call sites pass an update
callback any more — the bridge was a leftover from the pre-UserDraft
era when ScriptBuilder ran its own localStorage autosave and had to
be notified on every triggers mutation. The unified UserDraft handle
now deep-tracks `script.draft_triggers` via the $effect in
ScriptBuilder, so the callback channel is dead weight.

Removes the third constructor parameter, the private field, and the
six `this.#updateDraftCallback?.()` invocations across setters and
mutators.

* docs: review nits — variable.edited_at backfill, UserDraft toast/modal headers

Three low-priority callouts:

- Document the variable.edited_at backfill in the migration. All
  existing rows get a single `now()` timestamp from the column
  DEFAULT; the staleness check only consumes the field as an opaque
  rev string and never displays/sorts on it, so the collision is
  harmless — but worth saying out loud.
- Add module headers to userDraftToast.ts and LocalDraftStaleModal.svelte
  explaining how this layer sits above the per-browser UserDraft
  autosave and is distinct from the backend DraftService (the
  server-side "Save as draft" feature surfaced as `*.draft`).

* refactor(frontend): replace UserDraft.release() with useMany()

Public surface change:
- New `UserDraft.useMany(getSpecs: () => UserDraftSpec<V>[])` returns a
  reactive array of handles. The reconcile loop acquires entries for
  added specs, releases entries for removed specs, and re-uses cached
  handles for unchanged keys so caller-captured references stay stable.
- `UserDraft.use(kind, path, opts?)` becomes a 1-len wrapper around
  `useMany`. The spec getter is `untrack`ed so reactive opts
  (`$workspaceStore` etc.) are still captured-once — current `use()`
  semantics unchanged.
- `UserDraftHandle.release()` and the `manualRelease` option are gone.
  Component teardown is handled by a single internal `onDestroy` that
  releases every entry `useMany` acquired.

ResourceEditor + VariableEditor migrated:
- Replaced `Record<ws, Handle>` + manual `ensureHandle`/`release` with
  a `workspaceSpecs: $state<Array<{ws, defaultValue}>>` plus a
  derived `Record<ws, Handle>` that pairs each ws with its parallel
  handle from `useMany`. `ensureHandle(ws)` is now just a push to
  the specs array; `VariableEditor.reset()` clears it. The reconcile
  loop handles acquisition/release end-to-end.

Tests:
- Dropped the `manualRelease`/`release` test; the option no longer
  exists.
- Added a `useMany` test asserting per-spec entries, isolated
  workspace-scoped localStorage keys, and a single onDestroy
  registration covering every acquired entry.

Implementation note: I tried wrapping `useLocalStorageValue` in
`$effect.root` to give the entry's `$state`/`$effect` an independent
scope (in case `useMany`'s reconcile effect tore down nested effects
across cycles). But `$effect.root`'s callback wasn't running
synchronously in the test runtime (vitest + svelte-vite plugin), and
the original `use()` implementation called `useLocalStorageValue`
directly without issue. Reverted to the direct call; the
nested-scope concern stays theoretical.

* fix(frontend): isolate UserDraft entries via $effect.root

The previous commit landed `useMany` calling `useLocalStorageValue`
directly. That works for the `use()` 1-spec wrapper (whose getter is
untracked, so the reconcile `$effect` never re-runs), but for dynamic
specs (ResourceEditor / VariableEditor) it leaks the persist `$effect`
into the reconcile `$effect`'s scope — meaning the second spec change
would destroy the first entry's deep-mutation persist loop.

Wrap the `useLocalStorageValue` creation in `$effect.root` so the
entry's reactivity lives in its own scope. Stash the returned
disposer on the entry and invoke it when the refcount hits 0.

The vitest runtime's `$effect.root` returns its disposer but never
runs the callback (a test-env quirk, not a production behaviour).
Kept a documented fallback that calls `useLocalStorageValue` directly
when the callback doesn't populate `stateRef`. In tests that path
parents the persist `$effect` to the test scope and lives long
enough; in production `$effect.root` runs the callback synchronously
per the Svelte 5 spec and the fallback is unreachable.

* chore(frontend): drop leftover console.log in setDraftConfig

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): wire ?nodraft=true to actually skip the local autosave on /edit

The flows/apps/apps_raw `/edit` routes had a `?nodraft=true` handler
that just stripped the param from the URL via `afterNavigate` —
nothing behind it. The original pre-PR semantics (and what every
caller assumes) was "skip the localStorage autosave on this load."

Mirror the synchronous wipe pattern already in /add: when nodraft is
present, call `UserDraft.remove(kind, path)` and strip the flag from
the URL via `window.history.replaceState`, before the UserDraft handle
is created. The handle then reads an empty entry and the editor opens
on the backend version. A plain reload (no nodraft) restores the
autosave normally.

Removed the redundant `afterNavigate` blocks. Dropped the now-unused
`afterNavigate` import in all three; apps/edit still imports
`replaceState` (used downstream), so only that name stayed.

* feat(frontend): GC UserDraft entries older than 30 days

Without a sweep, a heavy user accumulates one localStorage entry per
(workspace, kind, path) they ever touched. The pre-PR single-key
autosave self-capped at one entry per editor; this one needs an
explicit GC pass.

Mechanism:
- Stamp every persist with `lastWrittenAt: Date.now()`. Added at four
  sites: `useLocalStorageValue`'s new `transformBeforePersist`
  option (covers both setter and deep-mutation persists),
  `UserDraft.save`'s no-handle fallback, `persistDirect` (force-meta
  writes), and the legacy migration. Done at persist time, not in
  `wrap()`, so deep mutations bump the clock too — `wrap()` runs only
  on `.draft =` assignments, which would leave the timestamp stale for
  bind-mutated editor sessions.
- `gcUserDrafts(maxAgeMs = 30d)` walks every `userdraft/w/...` key,
  removes the ones older than the cutoff. Entries written before this
  field existed (pre-PR or pre-this-commit) get backfilled with the
  current time on first sweep so a 30-day clock starts fresh; the
  alternative — sweeping on sight — would wipe work that the legacy
  migration just rescued.
- Wired into the logged-in layout: runs once on mount and every 30 min
  via `setInterval` (cleaned up in the effect's return).

Tests use `vi.setSystemTime` to drive the clock; assertions on the
stored payload now go through a `storedShape` helper that strips
`lastWrittenAt` before string-comparing, so the existing
`expect(...).toBe(wrapped(...))` style still reads cleanly. New tests
cover the sweep, the backfill behaviour, the default 30d window, and
a custom `maxAgeMs`.

* fix(frontend): break useMany reconcile feedback loop

The reconcile effect read `handles.length` / `handles[i]` for the
"unchanged?" early-exit optimisation and then `handles.splice(...)`
to publish the new array. Reading `handles` inside the effect
registered it as a dependency; the subsequent splice re-fired the
effect; ad infinitum (Svelte threw
`effect_update_depth_exceeded`).

Wrap the comparison reads in `untrack` so the effect's only
tracked dependency stays `getSpecs()`. The splice still fires the
downstream readers of `handles` (the whole point of `useMany`'s
reactivity); it just doesn't re-enter its own producer.

* fix(frontend): untrack the splice's own .length read in useMany reconcile

The previous fix wrapped only the comparison reads in `untrack`, but
`handles.splice(0, handles.length, ...next)` still reads `.length`
under the effect's tracking scope — same feedback loop, same
`effect_update_depth_exceeded`.

Move the whole "compare + splice" block inside `untrack`. The
downstream notification on splice still fires (untrack suppresses
dependency subscriptions on the producer side, not write
notifications), so consumers of `handles` still re-render.

* nit

* fix(frontend): drop in-memory handle before reloading after DB-draft discard

When the "Script/flow loaded from latest saved draft" toast's
"Reset to deployed" action ran, it:
1. Deleted the DB draft via DraftService.deleteDraft.
2. Called UserDraft.remove (clears localStorage only).
3. Called goto + loadScript / loadFlow.

But the handle's in-memory state still held the now-deleted DB draft
and its meta (remoteDraftRev pointing at the gone draft's created_at).
On the reload, the editor's loadScript/loadFlow saw `localDraft !=
undefined` and ran the staleness check, which compared
`meta.remoteDraftRev = <old timestamp>` against
`currentDraftRev = undefined`. Verdict: "version" stale → spurious
"A newer version was deployed on the server" modal, even though
nothing on the server actually moved. The editor visibly froze
behind the modal because the in-memory state wasn't refreshed.

Drop the in-memory state with `handle.setDraftAndMeta(undefined, {})`
before the reload — same fix already applied to the
"Restored from local storage > Reset to deployed" toast action.

apps/edit and apps_raw/edit's "discard draft" actions don't call
DraftService.deleteDraft (they just swap the in-memory view to the
deployed branch), so they don't hit this codepath.

* fix(frontend): drop in-memory handle in DiffDrawer restoreDraft/restoreDeployed

Same UserDraft.remove-without-clearing-in-memory bug as the previous
two commits, this time in the DiffDrawer's "Restore to draft" /
"Restore to deployed" buttons on all four /edit routes. The handler
deletes the DB draft (in the deployed case), wipes the localStorage
entry, navigates, and reloads — but the route's UserDraft handle
still holds the old draft + meta in memory, so the reload's
staleness check compares the stale meta against the freshly fetched
backend and surfaces a spurious "newer version was deployed" modal.

- scripts/edit, flows/edit, apps_raw/edit: route-level handle —
  `handle.setDraftAndMeta(undefined, {})` before the reload.
- apps/edit: the handle lives in the AppEditor child, so force a
  remount by setting `app = undefined; redraw++` before goto/loadApp
  (matches the existing pattern from the toast's onResetToDeployed).

* fix(frontend): legacy app migration matches actual stored shape

Legacy AppEditor wrote `encodeState($appStore)` — the inner App value
(grid/fullscreen/theme/unusedInlineScripts/hiddenInlineScripts), not the
wrapping AppWithLastVersion. The plausibility check was matching the
wrapping fields, so real legacy app entries were filtered out and never
migrated to the new userdraft/w/{ws}/app/{path} keys.

* fix(frontend): untrack meta-preservation reads in UserDraft setters

`set draft`, `setMeta`, `UserDraft.save`, and `UserDraft.saveMeta` all
read `state.val` before writing it (to preserve existing rev metadata).
When called from inside a `$effect` — as AppEditor does to mirror its
reactive `$state` into the handle — the read subscribes the effect to
the entry's `$state` cell that the write then mutates, producing an
`effect_update_depth_exceeded` loop. Wrap the reads in `untrack` so
mirrors don't self-trigger.

* fix(frontend): apps detect drift + restore on /apps/add reload

Two related issues in the app editor's UserDraft wiring:

1. Drift wasn't detected on first deploy/draft after starting an
   autosave. The route only backfilled meta on a reload that found a
   local diff — so the first external change after editing slipped
   through with empty `previousMeta`. AppEditor now receives the
   load-time revs as `initialRevs` and seeds them into the handle's
   meta on the first mirror, capturing the rev at autosave-creation
   time.

2. /apps/add didn't restore from LS on plain reload. The route
   always initialised `value` to `emptyApp()` and the AppEditor's
   `stateApp` captured the prop unconditionally, so the LS autosave
   was shadowed. `stateApp` now falls back to `appDraftHandle.draft`
   when present; the template/hub/import branches explicitly
   `UserDraft.remove('app', '')` to keep "start fresh from this
   content" semantics.

Also work around `useLocalStorageValue`'s `saveInitialValue: false`
skip slot — in the mirror pattern the slot survived past mount and
swallowed the user's first edit. Consume it up-front with a
wipe-then-restore pair so subsequent edits persist normally.

* feat(frontend): restored-from-local toast in resource/variable editors

Resource and variable editors silently loaded LS autosaves over the
backend value, leaving users with no signal that the form wasn't
reflecting deployed state. Both now fire the standard
`notifyRestoredFromLocal` toast (with a "Reset to deployed" action
that re-seeds the handle from the just-fetched backend) the first
time a lazy-fetch finds the local draft diverging from the remote.

* fix(frontend): add UserDraft.discard so "Reset to deployed" doesn't re-persist

The "Reset to deployed" toast action in resource/variable editors
called UserDraft.save with the backend value to repaint the form. That
left a duplicate-of-backend autosave in localStorage which would
silently restore on every subsequent reload, defeating the reset.

New UserDraft.discard(itemKind, path, fallback) clears LS AND resets
any live handle's in-memory state to the fallback, skipping the next
persist so the fallback doesn't round-trip back into storage. Backed
by a new `skipNextWriteOnce()` method on useLocalStorageValue's return.

* fix(frontend): use UserDraft.discard in apps reset flows

The apps editor route doesn't hold the UserDraft handle — AppEditor
(the child remounted by {#key redraw}) does. When a reset action ran
`UserDraft.remove` + `redraw++`, Svelte could mount the new AppEditor
before the old one's onDestroy released its handle, leaving the
entry's in-memory state.val populated with the stale autosave. The
new AppEditor would then re-acquire that entry and shadow the
just-emptied localStorage.

Switch every reset path (stale modal Load latest, restored-from-local
toast, DiffDrawer restoreDraft/restoreDeployed) to `UserDraft.discard`
so the in-memory cell is cleared synchronously alongside LS. Also
plumb `currentRevs` updates so the next mount's initialRevs reflects
the acked state.

* fix(frontend): /flows/add restores autosave on plain reload

`loadFlow()` initialised the local `flow` variable to `emptyFlow()`,
then passed it to `initFlow` which writes it to `flowStore.val` (=
`flowHandle.draft = flow`). On a bare /flows/add reload (no
template/hub/import/fork/urlHash) the assignment overwrote the
persisted autosave with the empty baseline. Seed `flow` from
`flowHandle.draft` instead, and keep `emptyFlow()` as the explicit
"start fresh" baseline for template/hub branches.

* nit rename

* fix(frontend): snapshot UserDraft proxy before structuredClone in resource save

`states[ws].draft` is now a Svelte $state proxy (it flows through
UserDraft's useLocalStorageValue cell). `structuredClone` can't clone a
proxy and threw "Failed to execute 'structuredClone' on 'Window'",
blocking resource saves. Snapshot to a plain object via
`$state.snapshot` before assigning the dirty baseline.

* fix(frontend): raw app deploy toast crash + harden Toast against bad type

RawAppEditorHeader's catch blocks called `sendUserToast(msg, e)`,
passing an Error as the `_type` arg. `classes[<Error>]` is undefined so
`color.descriptionClass` threw — and because the toast renders in the
root layout, it crashed the whole page on raw app deploy/create. Fixed
both call sites to the proper `(msg, true)` error form.

Also hardened Toast.svelte: coerce any non-AlertType `type` to 'error'
so a future miscall degrades to a plain error toast instead of taking
down the page.

* fix(frontend): /apps_raw/add restores autosave on plain reload

The route initialised files/runnables/data/summary to hardcoded
defaults, and the $effect mirror then wrote those defaults over the
persisted empty-path autosave. Seed the $state from
`draftHandle.draft` instead; import/template/hub branches
`UserDraft.remove('raw_app', '')` for explicit "start fresh"
semantics. Also consume useLocalStorageValue's saveInitialValue=false
skip slot (wipe-then-restore) so the user's first edit isn't dropped.

* feat(frontend): staleness modal in resource/variable editors

Resource/variable editors only showed the restored-from-local toast;
they never surfaced the staleness modal when the backend item moved on
since the local autosave was written. Wire LocalDraftStaleModal +
checkStaleness using the backend `edited_at` as `remoteRev` (these
items have no DB-draft concept). Meta is backfilled on reload for
legacy autosaves and seeded on the first real edit via a guarded
effect, so an external edit is detectable as drift. Per-workspace
detection; the modal is a singleton driven by `pendingStale`.

* feat(frontend): restored-from-local toast in standalone trigger editors

The schedule/postgres/http/kafka/websocket/email/sqs/nats/gcp/azure/
mqtt editors silently overlaid the local UserDraft autosave on top of
the backend config in `openEdit`, with no signal that the form wasn't
showing deployed state. Each now snapshots the just-loaded backend
config, then fires `notifyRestoredFromLocal` with a "Reset to
deployed" action that drops the LS entry and re-applies the snapshot.

* fix(frontend): trigger autosave no longer false-restores on plain open

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): live UserDraft handle for trigger editors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): live UserDraft sync for raw app editors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): extract useTriggerDraftSync composable

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(frontend): trim rot-prone comments in UserDraft

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* in /script, put code state in URL

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
2026-05-20 14:58:26 +00:00
centdix 413404a788 fix: collapse successful ai tool details (#9265) 2026-05-20 14:55:50 +00:00
Ruben Fiszel 00221128cb fix: cgroup-aware DuckDB memory_limit + allocator memory release (#9245) 2026-05-20 14:05:16 +00:00
Sahil Shah c4a86838fb set explicit cursor color in light editor theme (#9134)
The light Monaco theme ('myTheme') did not define editorCursor.foreground,
causing the cursor to be invisible on white backgrounds. The dark theme
('nord') already sets this explicitly.

Fixes #8876
2026-05-20 14:04:10 +00:00
Ruben Fiszel 22ec4da5f0 tighten security from vuln report (#9264)
* fix: harden app preview S3, WM_ env reservation, set_progress scoping

* fixup: minimize #1 fix to single SQL-level filter

* fixup: apply WM_* filter to HTTP agent-worker branch + normalize app S3 scope path
2026-05-20 14:02:50 +00:00
bb78b1c06d fix(s3): sandbox stored XSS via download response headers (#9263)
* [ee] fix(s3): sandbox stored XSS via download response headers

Reported chain: a workspace user uploads xss.html via
apps_u/upload_s3_file with content_type=text/html&content_disposition=inline;
when an admin clicks the resulting download URL the browser renders
the attacker page in Windmill's origin and can escalate via the
SameSite=Lax session cookie.

Fix on the download side only — leaves upload semantics unchanged so
existing integrations are not affected:

- download_s3_file_internal (used by apps_u/download_s3_file and
  job_helpers/download_s3_file) emits X-Content-Type-Options: nosniff
  and Content-Security-Policy: sandbox on every response (EE).
- The HTTP static-asset trigger emits the same headers on single-file
  responses. Static-website responses keep their existing semantics
  (CSP sandbox would break a legitimate static site); restricting
  write access to those buckets remains the documented mitigation.

Sandbox loads any HTML/SVG into an opaque origin so the page cannot
reach the viewer's cookie or /api/*. Images, PDFs, and fetch-driven
previews are unaffected (browsers ignore CSP for <img>/<embed> and
for fetch responses).

Companion: windmill-ee-private fix/s3-content-type-xss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to daffe7bb81cfcaca666c61de1ee838a44d60ebc2

This commit updates the EE repository reference after PR #585 was merged in windmill-ee-private.

Previous ee-repo-ref: e889b86ee1c68c2f7cf9b07ec4b8ba6e6b66a169

New ee-repo-ref: daffe7bb81cfcaca666c61de1ee838a44d60ebc2

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-20 13:59:39 +00:00
9c28bbfd69 feat(frontend): new path component (#9017)
* stash

* ui nits

* Fix contenteditable feedback look (duplicate typing)

* fix right icon wrong position with placeholder

* user editor in Path editor takes correct width

* nits

* nit

* chore: remove assets-operator changes (moved to separate PR)

These files were mistakenly included in this PR and belong in a dedicated PR
("Allow assets page to operators").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: remove sidebar assets-operator change (moved to separate PR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix disabled

* border nit

* Fix disabled styling

* Apply suggestion from @cubic-dev-ai[bot]

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* nit

* Update frontend/src/lib/components/text_input/TextInput.svelte

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Fix disabled tabindex and aria-disabled on contenteditable Select

The useContentEditable branch had an unconditional tabindex="0", keeping
a disabled Select in the tab order, and was missing aria-disabled.
Mirror the TextInput div branch.

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>

* fix: drop obsolete hideFullPath prop from EditorHeader Path usage

* invalidate autocomplete paths on deploy

* nit pixel

* use Badge in auto complete

* nit prevent default

* fix(autocomplete): don't let stale fetch clobber forced refresh

A non-forced fetchWorkspacePaths() that started before invalidateWorkspacePaths()
could still resolve afterward, overwrite the cache, and clear forceNextFetch —
making the post-deploy refresh a no-op. Only write back from the promise that
is still the current pending one, and only clear the force flag when the
completing fetch was itself forced.

* refactor(path): drop unreachable 'group' branch in owner-kind setter

The Select only offers user/folder, so the 'group' branch was dead. Leave a
short note pointing at validateName which still accepts 'group' for
forward-compat.

* fix(path): respect disableEditing on owner-kind selector

Other path-editor controls disable on (disabled || disableEditing); the
owner-kind Select only checked `disabled`, so read-only users (trigger
editors with !can_write) could still toggle User/Folder and mutate the
bound path. Reuse the existing nameDisabled flag.

* Revert "fix(autocomplete): don't let stale fetch clobber forced refresh"

This reverts commit 6649975714.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
2026-05-20 13:26:34 +00:00
Ruben FiszelandClaude Opus 4.7 9111f8908d feat(nsjail): make tmpfs size configurable via instance setting (#9261)
* feat(nsjail): make tmpfs size configurable via instance setting

Adds a new `nsjail_tmpfs_size_mb` instance setting that overrides the
size of the `/tmp` tmpfs mount inside the nsjail sandbox across all
languages. When unset, the existing per-language defaults (500MB or
800MB) continue to apply, so no behavior change for existing
deployments.

The setting is exposed under Settings → Jobs and is read at job
execution time, so changes take effect on the next job without a
restart.

Fixes WIN-1963

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(nsjail): unify default tmpfs size to 800MB

Previously each executor passed its own per-language default (500MB or
800MB) to resolve_nsjail_tmpfs_size. Unify on a single
DEFAULT_NSJAIL_TMPFS_SIZE_BYTES constant (800MB) so the placeholder
behavior is consistent across languages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nsjail): resolve tmpfs size outside ruby download closure

The download.ruby config render runs inside a sync closure passed to
par_install_language_dependencies_seq, so `.await` on
resolve_nsjail_tmpfs_size() was a compile error under the `ruby`
feature. Resolve the size once before the closure and capture the
string instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(nsjail): rename resolver to *_bytes and clarify fallback

Addresses CI review feedback:
- Rename `resolve_nsjail_tmpfs_size` to `resolve_nsjail_tmpfs_size_bytes`
  so the returned unit is unambiguous at the call site (cubic P2).
- Fix the `NSJAIL_TMPFS_SIZE_MB` doc comment that still said "per-language
  default" — there is no per-language fallback anymore, all unset
  values resolve to the unified 800MB `DEFAULT_NSJAIL_TMPFS_SIZE_BYTES`
  (codex/pi P2).
- Expand the resolver doc to call out that `Some(0)` and negative values
  also fall back, since the match arm is `Some(mb) if mb > 0`.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:24:49 +00:00
Guilhem 271f0cbd08 feat(debug): show ghost breakpoint and tooltip on gutter hover (#9150)
* feat(debug): show ghost breakpoint and tooltip on gutter hover

* fix(debug): show ghost breakpoint only on glyph margin to match click handler

* revert(debug): show ghost across entire gutter, not only glyph margin

* refactor(debug): use MouseTargetType enum, short-circuit hover decoration
2026-05-20 12:57:29 +00:00
GuilhemandClaude Opus 4.7 b0ed27096d feat(editors): responsive top-bars + collapsible raw-app sidebar (#9237)
* feat(editors): responsive top-bars + script test-pane pixel-min + flow graph overlay

Editor top bars now collapse on narrow widths (measured via container
clientWidth, not viewport — they live inside drawers / session panes
where the viewport stays wide):

- FlowBuilder: Diff + Save draft fold into the ellipsis menu when
  the top bar narrows below 720px (Save draft keeps its ⌘S / Ctrl+S
  shortcut indicator). Test-flow button moves out of the top bar
  and into a graph-pane overlay matching the dev page; the overlay
  position flips from top-2 right-2 to top-14 left-1/2 when the
  graph pane itself is narrower than 800px. FlowEditor exposes a
  graphOverlay snippet prop for that.
- ScriptBuilder: Settings + Draft labels collapse to icon-only;
  a new DropdownV2 ellipsis surfaces Tag / Settings / Save draft
  when even icons don't fit. The ellipsis itself uses variant=subtle.
- AppEditorHeader / RawAppEditorHeader: fullscreen / dark-mode /
  breakpoint toggle group + Debug-runs / Jobs buttons hide; Save
  draft moves into the Deploy dropdown.
- EditorBar: a "Helpers" DropdownV2 collapse for Context var /
  Variable / S3 / Resource / Git repo / Resource type / Database /
  Ducklake / Data table / Reset when the bar narrows below 800px
  (EDITOR_BAR_HELPERS_COMPACT_THRESHOLD). Above that, the existing
  icon-only mode (1420px threshold) still applies.
- ScriptEditor's test pane gets a pixel-based minimum width (400px)
  derived from the splitpane's clientWidth. The Pane uses Svelte 5
  function-binding so the splitter writes to a raw $state while the
  splitpane reads the clamped derived value — no $effect, no
  release-time bounce, drag stops at the boundary. Cap raised to
  80% so the test pane can take most of the editor on very narrow
  layouts while leaving a sliver of code visible.
- VS Code button on ScriptEditor: collapses to icon-only below the
  EDITOR_BAR_WIDTH_THRESHOLD (1420px) instead of being hidden
  entirely by viewport `lg:` breakpoint; hidden completely when the
  editor is rendered inside a session pane.
- AI wand button on ScriptEditor + RawAppEditorHeader: hidden inside
  a session pane (detected via `getContext('aiChatManager')`) — the
  session owns its own AI chat.
- DeployButton: drops the unused `newFlow` gate (callers updated).
- FlowDiffViewer / FlowGraphDiffViewer: inlineDiff prop forwarding
  + onHeight callback on FlowGraphV2 so diff viewers can equalize
  side-by-side graph heights.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui: unify Debug / wand / test-toggle button sizes + HideButton defaults

Two small consistency passes on shared button components:

- ScriptEditor's Debug, AI wand and Test-panel-toggle buttons all
  use unifiedSize="sm" so they line up in the toolbar; Test toggle
  switches from custom marine btnClasses to variant="accent-secondary".
  HideButton gains a passthrough unifiedSize prop so the wand and
  test toggle can match Debug without overriding btnClasses.
- HideButton's own defaults shift to variant="subtle" + sm
  unifiedSize, dropping the legacy color="light" / variant="contained"
  + tailwind-merge background overlay; the selected (hidden) state
  is now a tinted wrapper div instead of overriding btnClasses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(script): editor toolbar polish

Small consistency tweaks on the script editor's top-right overlay:

- Lowercase "test" / "Exit debug" panel labels.
- `bg-surface` on the overlay container so the absolute-positioned
  buttons read as a single panel over the graph rather than disjoint
  pills.
- Debug button picks up `destructive={debugMode}` so the active
  state reads as "you're in debug mode" instead of accent.
- Console and "Delegating to git repo" buttons drop the custom
  `btnClasses` border-on-surface treatment and switch from
  `size="xs"` to `unifiedSize="sm"` so they match the other buttons
  in the cluster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(raw-app): collapsible file sidebar, default-collapsed in session preview

The raw-app editor's left sidebar (file tree, runnables, history) ate a
lot of horizontal space — fine in the standalone editor, painful in the
session preview pane where the chat is already taking half the screen.

Add a small collapse / expand toggle. Persist the user's preference in
localStorage so it sticks across opens.

Two independent localStorage keys via the new `sidebarStorageKey` prop:
- standalone editor: `raw-app-sidebar-collapsed` (default expanded)
- session preview:   `raw-app-sidebar-collapsed-preview` (default collapsed)

Otherwise the two contexts would race for the same key — whichever
opens first would dictate the other's default. Splitting the keys lets
each have its own remembered preference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fixup(editors): use untyped getContext for AI-chat-manager session detection

The cherry-picks landed `getContext<AIChatManager>('aiChatManager')` to
hide per-editor AI/VSCode buttons when rendered inside a session pane.
The `AIChatManager` class is exported only on the sessions branch (used
for typing session-provided manager overrides). On `main` the manager
file exports only the singleton instance, so importing the class fails
the type-check.

The session-pane detection just needs a truthy/falsy probe — drop the
type parameter and the class import. `inSessionPane` ends up as
`getContext('aiChatManager')` (returns `unknown`, coerced to boolean
via `!!`). Same runtime behaviour, no class-export dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui(editors): flow test in top bar; ellipsis folds draft/jobs/tutorials

* refactor(app-editor): drop dead AppEditorTutorial button path

* ui(editors): wire compactHelpers in flow-step + raw-app inline editors

* ui(raw-app): sidebar Cmd/Ctrl+B toggle + uppercase section titles

* ui(editors): keep Diff/Settings inline as icon-only when narrow

* fix(editors): address review nits on test-pane/Helpers/thresholds

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:55:20 +00:00
Guilhem 7909878313 feat(chat): waiting-for-user indicator + scroll-to-latest polish (#9252)
* feat(chat): waiting-for-user indicator and arrow polish

- Show "Waiting for your input" (text-accent + flipping Hourglass) instead
  of the typing dots when the latest tool is staged for confirmation
  (Run/Cancel) or has an active askUserQuestion. The dots imply the AI
  is working, which is misleading when the loop is paused on the user.

- Scroll-to-latest arrow:
  - Move up to bottom-12 when the flow Accept/Reject row is visible so
    they no longer overlap.
  - Wrap in a solid bg-surface + shadow + border badge so the icon
    doesn't bleed into messages behind it.
  - Bump unifiedSize xs → sm for a slightly larger target.

- Hourglass uses a custom CSS keyframe (:global so the rule reaches the
  Lucide SVG root) with 4 s period and cubic-bezier(0.65, 0, 0.35, 1)
  easing — feels like flipping the hourglass rather than spinning.

* fix(chat): raise waiting indicator above accept/reject row

* fix(chat): solid background behind reject all button

* feat(chat): @ picker in controls row, badges above input, polish
2026-05-20 12:54:23 +00:00
Diego ImbertandClaude Opus 4.7 31b781000e feat(frontend): sync home search bar state to URL (#9256)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:54:01 +00:00
Diego Imbert 1d04904a47 feat(cli): add datatable and ducklake list/run commands (#9257)
* feat(cli): add datatable list and run commands

* feat(cli): render datatable query results as a table

* feat(cli): add ducklake list/run, scope --name to run subcommand
2026-05-20 12:50:39 +00:00
Ruben Fiszelandwindmill-internal-app[bot] 78cf6c7f81 fix(saml): preserve deep links from /a/[...path] across SAML round-trip (#9259)
* [ee] fix(saml): preserve deep links from /a/[...path] across SAML round-trip

Fixes WIN-1962.

PR #9225 only covered users who pass through /user/login on their way to
the IdP — that's where `redirectSaml()` runs and where the deep link gets
stuffed into `RelayState`. The reported flow doesn't go through that
page: it hits `/a/[...path]` (the public-app custom-path route, outside
the `(logged)` layout) where `PublicApp.svelte` renders its own `<Login>`
and was passing `page.url.toString()` as `rd` — the full URL.

Three problems compounded:

1. `redirectSaml()` only set `RelayState` when `rd.startsWith('/')`,
   so a full URL silently fell through and the deep link was lost.
   The IdP echoed back the SP-library default (BASE_URL), which the
   ACS validator correctly rejected as a potential open-redirect.
2. `persistRd()` stored the full URL in `localStorage.rd`. On the
   fallback landing at `/user/login`, the post-login redirect saw
   an `http://...` value, hit the cross-origin branch, and bounced
   to `/` — which from a logged-in but workspace-less state shows
   the "Loading user…" modal forever (bug 2).
3. The EE `safe_relay_state_redirect` validator rejected any full
   URL, including same-origin ones, so even IdPs that prepend the
   origin or that pass a configured absolute deep link via
   IdP-initiated SSO got dropped on the floor.

The fix is a single concept applied at every layer: reduce a redirect
target to a safe same-origin relative path, or refuse it.

Frontend:
- `logoutRedirect.ts`: new `toSameOriginRelativePath(rd)` helper that
  accepts both `/foo` and `https://current-origin/foo`, with the same
  open-redirect guards as the backend (length cap, control chars, no
  protocol-relative or back-slash tricks). Returns `null` for
  cross-origin or malformed input.
- `PublicApp.svelte`: pass `pathname + search + hash` to `<Login>`
  instead of the full URL — this alone fixes the happy path.
- `Login.svelte`: `redirectSaml()`, `persistRd()`, and `redirectUser()`
  all route through the helper, so full URLs from `/a/[...path]` are
  reduced before being put in `RelayState`/`localStorage`/`goto()`.
- `/user/login/+page.svelte`: the same reduction is applied to the
  resolved `rd` so any stale full-URL value in `localStorage.rd` still
  navigates to the intended page instead of falling into the
  cross-origin branch.

Backend (EE companion: windmill-ee-private#TBD):
- `safe_relay_state_redirect` now reduces a `RelayState` whose origin
  matches `BASE_URL` to its path before applying the same-origin path
  safety rules. Bare BASE_URL with no path still falls back to
  `/user/login` (no useful deep link to honor).
- New `same_origin_relative_path` helper + expanded unit tests.

Test plan:
- [x] Frontend: `vitest run src/lib/logoutRedirect.test.ts` — 9 passed
- [x] Backend: `cargo test -p windmill-api ... saml_ee::tests` — 3 passed
  (`honors_same_origin_relative_path`, `reduces_same_origin_full_url_to_path`,
  `falls_back_on_open_redirect_attempts`)
- [ ] Manual e2e (needs configured SAML IdP — not on local CE):
  - Unauthenticated visit to `/a/<path>` → click SSO → SAML → land on
    `/a/<path>` (RelayState now carries the relative path).
  - IdP that echoes BASE_URL as default → ACS still falls back to
    `/user/login` (no useful path to honor), but the page no longer
    hangs: the stale full-URL `localStorage.rd` is reduced to its path
    and the post-login redirect navigates to it.
  - Tampered `RelayState` (`//evil.com`, `https://evil.com/x`) → ACS
    rejects, lands on `/user/login`.

* chore: update ee-repo-ref to 3489c243b0e5a8eb0dbc86e90917fbe72843573b

This commit updates the EE repository reference after PR #584 was merged in windmill-ee-private.

Previous ee-repo-ref: 635ff3eeb8e47bb84d5686942605f67f8f6224b4

New ee-repo-ref: 3489c243b0e5a8eb0dbc86e90917fbe72843573b

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-20 12:46:58 +00:00
2a780ad87a feat: resolve relative imports from local content in script/flow preview (#9233)
* feat: thread temp_script_refs into preview jobs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: resolve python preview relative imports from temp script refs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: use local relative imports in wmill script preview

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: use local relative imports in wmill flow preview

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add temp_script_refs to Preview and FlowPreview openapi schemas

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: pass temp_script_refs to bun lockfile gen for no-lock preview

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: route script preview through shared buildPreviewTempScriptRefs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: resolve local relative imports in wmill app dev inline scripts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address cubic review — bundle cache key, preview-mode gate, error masking

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf: skip dep-tree build when previewed script has no relative imports; narrow old-backend classifier

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review issues (preview-only gate, bundle preview, app dev cwd)

Three P1s flagged in repeated codex/pi reviews on PR #9233:

- Gate _TEMP_SCRIPT_REFS extraction on JobKind::Preview (bun + python
  executors) and propagation in worker_flow on JobKind::FlowPreview. job.args
  includes caller-controlled request args, so honoring this key on deployed
  runs would let a caller swap import resolution to local content uploaded
  via /raw_temp.
- run_bundle_preview_script now injects temp_script_refs into PushArgs.extra,
  mirroring run_preview_script — closes the silent data drop for the bundle
  preview path.
- wmill app dev chdirs to the wmill.yaml root before buildPreviewTempScriptRefs
  and restores after, so the `cd <app>__raw_app && wmill app dev` invocation
  (cwd is the raw_app folder, no app_folder arg) still walks sibling workspace
  scripts like f/lib.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ee): bump ee-repo-ref to 5b347d6 (handle_python_deps arity fix)

Picks up the EE arity fix so cargo_test + check_ee_full compile cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ee): bump ee-repo-ref to 52e273d (agent-workers bundle path arity fix)

Picks up windmill-ee-private 52e273d which adds the missing &None arg to
compute_bundle_local_and_remote_path in windmill-api-agent-workers/src/ee.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ee): bump ee-repo-ref to 2d6ffd3 (EE main merged in)

Previous bump pinned an older EE commit, missing the audit-log object-store
export module (EE PR #579, commit ec3cd35) and other EE main updates. The
CE backend's `crate::ee_oss::anchor_audit_logs_s3_checkpoint_env_var` and
`export_audit_logs_to_object_store` references need the new EE definitions.
Merged origin/main into the EE branch and pinned the merge commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to b0c87b1272c25dca4aa9148c87fb024a9d9ef322

This commit updates the EE repository reference after PR #583 was merged in windmill-ee-private.

Previous ee-repo-ref: 2d6ffd32c99bd93e79cf78675cb89499a81b17e1

New ee-repo-ref: b0c87b1272c25dca4aa9148c87fb024a9d9ef322

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-20 12:44:29 +00:00
f6fcdb5599 feat: open ai chat path links in drawers (#9220)
* feat(ai-chat): link workspace paths and show tool item references

Detect Windmill paths (u/..., f/...) in assistant messages and render
them as clickable pills with the right icon, resolved against a per-
workspace cache. Tool execution headers now list the script/flow/app
paths referenced in tool parameters as external links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): linkify inline-code paths, refine pill styling

- Inline-code spans whose value is exactly a Windmill path now render
  as a link pill (paths inside larger inline code or fenced blocks
  stay as code).
- Tool-header chips moved to their own row to avoid overflow clipping
  when the title wraps.
- Borderless pills, no default background (hover only), kind icons
  use the home-page palette (script blue, flow teal, app orange),
  and the external-link indicator only appears on hover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): linkify variables/resources/triggers + inline drawer

- Workspace item registry now also lists variables, resources, schedules,
  and all 10 trigger kinds; resource wins over variable on path collisions
  (Windmill auto-creates a companion variable for every resource).
- Pill icons delegated to the canonical RowIcon component so each kind
  matches the home-page styling (script blue, flow teal, app orange,
  resource boxes, schedule calendar, etc.).
- Pill href includes the hash fragment each list page already consumes
  (#/resource/<path>, #<path> for variables/schedules/triggers), so
  opening the link puts the user on the list page with the matching
  editor drawer already open.
- For variable and resource pills, a hover-revealed side-panel button
  opens (or toggles closed) the editor drawer inline next to the chat,
  without navigating away. VariableEditor and ResourceEditorDrawer gain
  a closeDrawer() export and forward their close event so the host can
  drive toggling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: simplify ai chat workspace item links

* refactor: keep ai chat path linkification only

* perf: avoid eager ai chat path cache loads

* refactor: simplify ai chat path linking

* feat: open ai chat path links in drawers

* refactor: homogenize workspace item kinds

* fix: toggle ai chat item drawer

* refactor: trim ai chat path cache

* fix: cancel ai chat drawer reopen

---------

Co-authored-by: Guilhem Lemouel <guilhemlemouel@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-05-20 10:00:16 +00:00
Diego ImbertandClaude Opus 4.7 cc141effa3 fix(frontend): flow progress bar for early-stop completion and error handler (WIN-1961) (#9254)
Two FlowProgressBar bugs:

1. stop_after_if (without 'label as skipped') ends the flow with
   step < modules.length, leaving the bar at <100% with a spinner.
2. failure_module execution drives step past modules.length, so the bar
   overflows past 100% and never reflects the error.

The fix clamps progress to the failed module when the error handler
runs, and forces 100% Done when the flow completed successfully but
stopped early.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:54:09 +00:00
76d949e7bc fix(autoscaling): count custom worker groups by row, divide only native by NUM_WORKERS (#9255)
* [ee] fix(autoscaling): only divide native_mode pings by NUM_WORKERS

#9020 / EE #548 changed worker counting to COUNT(DISTINCT worker_instance) to
fix native-mode over-counting (NUM_WORKERS=8 pings per pod). That collapsed
custom worker groups that share a hostname across multiple worker processes
to a count of 1, breaking their autoscaling.

EE fix uses the per-ping native_mode flag: divide native rows by 8 (CEIL),
count non-native rows as-is.

Companion EE PR: windmill-labs/windmill-ee-private#fix-autoscaling-custom-worker-group-count

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to abeb405de36a3fe23382f19e762268f87b6679be

This commit updates the EE repository reference after PR #582 was merged in windmill-ee-private.

Previous ee-repo-ref: 135db676843346ed6e3015232161a49c3ce01db5

New ee-repo-ref: abeb405de36a3fe23382f19e762268f87b6679be

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-20 09:43:38 +00:00
hugocasa 79492d6ccf chore(webmux): add oneshot system prompt with PR readiness guidance (#9253)
Defines a top-level oneshot.systemPrompt block so webmux oneshot runs
get explicit guidance: no interactive user, take the task through PR,
and only mark a PR ready-for-review when highly confident (otherwise
draft).
2026-05-20 09:29:26 +00:00
GuilhemandClaude Opus 4.7 31a046973a feat(chat): visual redesign — input, streaming indicator, scroll polish (#9232)
* feat(chat): visual redesign — input, streaming indicator, scroll polish

Visual refresh of the AI chat surface used in both the global right-side
panel (Cmd+L) and inline editor panels. No new features, no system-prompt
or tool changes, no sessions code.

Input redesign
- Default textarea to `rows={1}` and autosize as the user types.
- Drop the separate Send button row in favour of a single
  `<Button variant="subtle" iconOnly>` overlaid bottom-right of the
  textarea — `ArrowUp` when idle (disabled until text is typed),
  `Square` when loading (cancels via `aiChatManager.cancel()`).
- Padding `!pl-3 !pr-10 !py-2` keeps text clear of the floating button.
- Top spacing `mt-1` on the outer wrapper restores breathing room
  above the input (lost when the old @-button row was removed).
- Context chip row renders only when something is selected.
- `ContextTextarea` `min-height: 2.25rem` so the empty textarea
  collapses to a tight single line.

Streaming indicator
- Replace the old floating "Stop" button with a sticky-bottom badge
  showing three animated typing dots and a formatted wall-clock
  (`Xs`, `Xm Ys`, `Xh Ym`) — driven by `aiChatManager.loading`.
- CSS keyframes `chat-typing` with staggered animation-delay for the
  wave effect.

Scroll behaviour
- Replace `onwheel`-based stick-to-bottom detection with `onscroll`
  position check (8px threshold). Auto-scroll re-engages when the
  user scrolls back near the tail.
- Smooth scroll → `behavior: 'auto'` so token-append doesn't race
  the animation.
- New `enableAutomaticScroll` method on `AIChatManager`, complement to
  the existing `disableAutomaticScroll`.
- Floating "scroll to latest" arrow (`ArrowDown` design-system Button,
  `transition:fade`, `unifiedSize="xs"`, `iconOnly`) appears once the
  user scrolls >200px above the tail; click re-enables auto-scroll
  and jumps to bottom. Centered horizontally over the scroll viewport.

Message rendering
- Assistant markdown tuned: `prose-headings:font-medium`, h1 `text-sm`,
  h2+ `text-xs`, plus `prose-p:text-xs prose-li:text-xs
  prose-code:text-xs prose-pre:text-xs`. Stops AI replies blasting
  oversized titles.
- Fenced code blocks shrink to `!text-xs` on the `not-prose` wrapper
  so fenced code matches inline code at 12px.
- User-message wrapper switches to symmetric spacing (`mt-4 mb-6`)
  with a new `isLast` prop that adds `!mb-12` to the latest message
  — breathing room between the last bubble and the input without
  affecting siblings.

Layout / padding
- Wide-layout messages tightened to `px-7` (was `px-8`); input outer
  to `px-6`. The input box sits a touch left of the message text;
  textarea's own `!pl-3` brings the typed text back into alignment
  with the messages above.

Other
- `AIChatManager` class is now exported (was private). Allows callers
  to type a `getContext<AIChatManager>('aiChatManager')` provider
  override. No behaviour change for the global singleton.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(chat): restore @ picker, extract typing indicator and shared helpers

* feat(chat): cap non-wide chat at max-w-2xl, add side padding, drop input top border

* feat(chat): esc cancels active generation, tone down snapshot row

* fix(chat): only draw tool-content fade when content actually overflows

* style(chat): tighten non-wide side padding (px-4/px-3 -> px-3/px-2)

* fix(chat): inline ⌘K shows dots + stop button, swallow programmatic scroll events

* fix(chat): keep scroll-to-latest fresh during cooldown; ResizeObserver for tool-content fade

* fix(chat): contain wide content - propagate showFade, table scroll, bubble + inline code wrapping

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 07:10:25 +00:00
Ruben Fiszel f066c3df1f chore: allow claude to do stuff in /tmp 2026-05-20 07:07:32 +00:00
Ruben FiszelandClaude Opus 4.7 aa12c66c25 feat(snowflake): derive public key from private key when omitted (WIN-1959) (#9251)
* feat(snowflake): derive public key from private key when omitted (WIN-1959)

Snowflake key-pair auth needs a SHA256 fingerprint of the public key for
the JWT iss claim, but the public key is mathematically derivable from
the RSA private key. Other tools (e.g. Power BI) only require the
private key, so requiring users to supply both is redundant. When
public_key is missing, fall back to deriving it from private_key (PKCS#8
or PKCS#1 PEM) instead of erroring out.

Fixes WIN-1959

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snowflake): treat empty public_key/private_key as missing

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:48:10 +00:00
hugocasaandClaude Opus 4.7 ef0cb49f74 chore(claude): harden main-branch guard and gate claude.ai MCP tools (#9248)
- guard-main-branch.sh: exit 2 on block (was advisory echo), and block
  force-push to main from any branch (--force, -f, --force-with-lease, +ref)
- settings.json: gate claude.ai MCP connectors (Stripe, Gmail, Calendar,
  Drive, Slack, Linear) behind permissions.ask

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:47:02 +00:00
d08f72b3e1 feat(vault): optional KV secret path prefix setting (WIN-1960) (#9249)
* feat(vault): add optional KV secret path prefix setting (WIN-1960)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 0189ba6504fd70eb4929e4881d624d48efd14aee

This commit updates the EE repository reference after PR #581 was merged in windmill-ee-private.

Previous ee-repo-ref: e32e8d6483550c67897e09b6f900dff1034bdae8

New ee-repo-ref: 0189ba6504fd70eb4929e4881d624d48efd14aee

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-20 06:09:26 +00:00
hugocasa 457a78cc6a update webmux config for oneshot (#9250) 2026-05-20 06:02:53 +00:00
Ruben FiszelandClaude Opus 4.7 4b1bea8aed fix: enforce auth guards on app component preview execution (#9235)
* fix: enforce auth guards on app component preview execution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: guard previewed runnable path and worker tag in app preview

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: validate app_script id ownership and keep root push isolation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: scope app preview guards to operator check + referenced runnables

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: require jobs:run scope and tag check on app preview (apps:run escalation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:40:55 +00:00
Ruben FiszelandClaude Opus 4.7 01bad16c0c feat: add wmill protection-rules pull/push CLI commands (#9240)
* feat: add wmill protection-rules pull/push CLI commands

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: use directional keys for protection-rules pull --json diff

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review — exit non-zero on failure, resolve override workspace key

- failure paths in pull/push now exit 1 so CI/scripts detect failed reconciles
- --override writes under the resolved workspace key (findWorkspaceByGitBranch),
  not the raw branch, so gitBranch-mapped entries aren't left inert
- pull --replace clears a shadowing protectionRules override so top-level takes
  effect (was an infinite pull --diff loop)
- push reports applied create/update/delete counts on partial failure and warns
  loudly when an empty list would wipe all backend rules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review — dry-run pull --diff no longer writes; --promotion coherent

- pull --diff returns before the no-wmill.yaml bootstrap, so a dry run never
  creates/mutates wmill.yaml
- pull --promotion now writes/clears the promotion target's promotionOverrides
  (the same block getEffectiveSettings reads), instead of the current branch's
  regular overrides — read and write are now coherent

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: move protection rules to a per-workspace protection-rules.yaml

Replaces the wmill.yaml/SyncOptions integration (top-level + overrides +
promotionOverrides) with a dedicated protection-rules.yaml keyed by workspace
name. This removes the getEffectiveSettings layering that caused the override
shadowing / promotion-coherence / dry-run bugs entirely.

- protection-rules.yaml: { <workspace>: ProtectionRuleEntry[] }, keys must
  match wmill.yaml 'workspaces' (source of truth for backend id/baseUrl/token)
- commands reduced to: pull/push [workspace] | --all, with --dry-run
- per-workspace auth resolved via tryResolveBranchWorkspace + setClient
- push remains a full reconcile (create/update/delete) with delete confirm,
  empty-list wipe warning, partial-failure reporting, non-zero exit on failure
- conf.ts reverted to main; SyncOptions no longer carries protectionRules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review — honor explicit --base-url/--token in protection-rules

configureClientForWorkspace bypassed the credential precedence other commands
use: explicit --base-url/--token now work for stateless CI (no stored profile
or wmill.yaml baseUrl needed), and an explicit --token overrides a stored
profile's token. The backend workspace id still derives from the wmill.yaml
mapping (feature invariant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address cubic review — consistent status on partial --all failure

cubic found that pull/push reported success:true while exiting non-zero on
partial --all failures, and that the push command description was missing from
the generated CLI docs.

- pull/push now report success:false + partialFailure:true (and exit 1) when
  any --all workspace fails; success:true only on full success
- .description() calls use single string literals (not + concatenation) so
  system_prompts/generate.py parses them; regenerated CLI docs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review — --json-output must emit only JSON on stdout

Codex flagged that workspace resolution (tryResolveBranchWorkspace's log.info)
and push's empty-list delete warning print to stdout before the JSON payload,
breaking machine callers. Silence human logs via log.setSilent(true) as the
first action when --json-output is set (before readConfigFile / resolution);
log.error still goes to stderr.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:40:34 +00:00
Ruben FiszelandClaude Opus 4.7 355c837944 test: provision migrated db for mutual-resource recursion test (WIN-1958) (#9247)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:52:08 +00:00
285a78752a feat(indexer): observability for unavailable search index (WIN-1956) (#9239)
* [ee] feat(indexer): observability for unavailable search index

A user hit `Not found: There is no index reader to search from` when
searching service logs and could not tell whether it was a config
error or a bug, and asked for visibility into the indexer status
(WIN-1956).

Backend (EE companion PR):
- Replace the opaque error with an actionable message explaining the
  likely causes (indexer disabled, still starting, or blocked
  acquiring the indexer lock) and pointing to the status panel.
- Add a coarse `state` (running | stale | never_started) to
  `/indexer/status`, derived from the lock row, distinguishing a
  never-configured indexer from a stale/blocked one.

Frontend:
- Instance Settings > Indexer now shows Running / Stale / Not started
  with a tooltip explaining what to check for each.
- Service logs search now catches failures and shows an inline,
  actionable Alert instead of an unhandled rejection.

Fixes WIN-1956

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 017d36418a65ce5c840c502e3174df0c393612ba

This commit updates the EE repository reference after PR #580 was merged in windmill-ee-private.

Previous ee-repo-ref: 18b7e1b30a1ff582c4a072580bbb8aec34e22cdc

New ee-repo-ref: 017d36418a65ce5c840c502e3174df0c393612ba

Automated by sync-ee-ref workflow.

* fix(indexer): address review nits

- IndexerMemorySettings: older backends without `state` reporting
  `is_alive: false` now show "Stopped" (red) again instead of
  falling through to "Unknown" (codex/cubic P2).
- ServiceLogsInner: clear stale logs/counts on a failed search so the
  error isn't shown alongside results from a previous query (codex P2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-19 16:37:08 +00:00
Ruben FiszelandClaude Opus 4.7 26f3cbef25 fix: bound resource/variable interpolation recursion depth (WIN-1957) (#9243)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:39:42 +00:00
hugocasaandClaude Opus 4.7 f51b51a9a1 fix(frontend): open customer portal in popup synchronously to bypass Safari blocker (#9242)
* fix(frontend): open customer portal in popup synchronously to bypass Safari blocker

Safari blocks window.open() called after an await because it loses the
user-gesture context. Open a blank tab synchronously on click, then
assign location.href once the portal URL resolves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(backend): wire dev_override feature flag in backend crate

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:26:32 +00:00
Ruben Fiszel 07202fd048 feat(git-sync): hidden sync git-deploy owns wm_deploy branch + e2e regression tests (#9230)
* feat: add git-sync wm_deploy branch ownership to CLI sync pull + regression tests

* refactor: move git-sync deploy flags to hidden sync git-deploy subcommand

* feat: absorb git-sync include/promotion derivation into sync git-deploy

* fix: restore 1:1 fidelity with hub git-sync script (fork-disable, commit msg, gpg committer)

* feat(git-sync): default sync script to hub/28231 (thin CLI-delegating script)
2026-05-19 15:14:28 +00:00
Ruben Fiszelandwindmill-internal-app[bot] ba6fb7021b feat: export audit logs to a dedicated object store folder (#9207)
* feat: export audit logs to dedicated object store folder

* fix: gap-free audit export via snapshot-xmin gate and stable object keys

* test: add integration test for audit log object store exporter

* fix: cursor audit export on snapshot xmin to prevent id-leapfrog loss

* fix: protect audit s3 checkpoint from config sync and bound export interval

* fix: anchor audit s3 checkpoint at enable time to not skip first-window rows

* fix: anchor first audit export at the enable transaction's xid

* fix: use epoch timestamp floor on first audit export run to not drop old backlog

* fix: anchor audit export at startup for env-var enable path

* fix: anchor audit export via enabling-txn snapshot xmin trigger

* fix: bound the bootstrap audit export to MAX_XID_INTERVAL per tick

* refactor: store audit export cursor in background_task_state, add status endpoint

* docs: align store_audit_logs_s3 setting text with the actual enable-boundary contract

* [ee] refactor: move audit s3 export core logic to EE, gate on Enterprise license

* chore: update ee-repo-ref to ec3cd353245e1cdf6a290528dbd7f2ac2498386c

This commit updates the EE repository reference after PR #579 was merged in windmill-ee-private.

Previous ee-repo-ref: 4ffc6d5f874e64d7dc4a147b4e73baa6c44867a5

New ee-repo-ref: ec3cd353245e1cdf6a290528dbd7f2ac2498386c

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-19 14:43:54 +00:00
a4d59a81df fix(autoscaling): full-scale below min_workers on large backlog (#9234)
* fix(autoscaling): full-scale from below min_workers when backlog exceeds threshold

Bump EE ref to pull in the autoscaling fix: when active worker count is
below min_workers and a relevant tag's queue depth already exceeds
full_scale_jobs_waiting, scale straight to max_workers instead of slowly
ramping to min_workers first.

Companion EE PR: windmill-labs/windmill-ee-private#improve-pr-9209

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to b4d68e40430cf0300b5d37734d1505fd743f1059

This commit updates the EE repository reference after PR #578 was merged in windmill-ee-private.

Previous ee-repo-ref: 99810eb763703ef0f4b3311338e0e65f53544158

New ee-repo-ref: b4d68e40430cf0300b5d37734d1505fd743f1059

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-19 14:41:29 +00:00
centdix 55dcee2424 refactor: move anthropic proxy building (#9238) 2026-05-19 14:23:31 +00:00
Ruben FiszelandClaude Opus 4.7 a974ff68e0 fix: enable jemalloc background_thread to prevent worker RSS growth (#9236)
* fix: enable jemalloc background purge to prevent worker RSS growth

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: drop decay overrides, keep only jemalloc background_thread

The background thread is the actual fix; jemalloc's default decay
windows (dirty 10s, muzzy 0) are correct for months-long workers and
muzzy_decay_ms:5000 was more retentive than the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:53:30 +00:00
88c1493145 feat: add flow_user_state(key) to QuickJS input transform sandbox (WIN-1947) (#9093)
* Add flow_user_state(key) to QuickJS input transform sandbox

* fix: use root flow id for flow_user_state in QuickJS sandbox

* fix: url-encode key in get_flow_user_state

* fix: stub flow_user_state in eval contexts without by_id

---------

Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
2026-05-19 13:49:57 +00:00
Diego ImbertandClaude Opus 4.7 bd062825a2 fix: scope VSCode webview clipboard paste to focused editor (#9221)
* fix: scope SimpleEditor webview paste to focused editor instance

* fix: scope webview clipboard paste to focused editor instance

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: bail on missing selection instead of pasting at document start

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: hide SimpleEditor paste sink input from a11y tree and tab order

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:02:09 +00:00
Ruben Fiszel 8c1f6ccc5d fix: prevent undefined user flickering in multiplayer presence list (#9231) 2026-05-19 07:30:27 +00:00
Ruben Fiszelandrubenfiszel c8ab030aa4 chore(main): release 1.704.1 (#9226)
* chore(main): release 1.704.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-19 05:46:13 +00:00
Ruben Fiszel 05a38d312d fix: advance postgres replication slot lsn via periodic standby status updates (#9227)
* fix: advance postgres replication slot lsn via periodic standby status updates

* test: add e2e regression test for postgres replication slot lsn advancement

Drives sustained change traffic so the slot freeze reproduces deterministically (fails pre-fix at the 20s deadline, passes post-fix within ~10s). Also wires the postgres_trigger feature through windmill-test-utils and the integration-tests crate so the postgres trigger e2e tests are actually runnable.
2026-05-19 05:44:13 +00:00
Ruben FiszelandClaude Opus 4.7 9c6deec8ff avoid stale localStorage rd when SAML RelayState carries the deep link (#9228)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:40:48 +00:00
Ruben Fiszel ff1deaa7e2 fix: fix git sync 2026-05-19 05:35:37 +00:00
89306d7dbc fix: honor SAML RelayState to redirect to deep link after SSO login (#9225)
* fix: honor SAML RelayState to redirect to deep link after SSO login

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump ee-repo-ref for SAML RelayState validator test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to a3fefe85f5f2f52bb473fa47acc9efa8fd0b2206

This commit updates the EE repository reference after PR #577 was merged in windmill-ee-private.

Previous ee-repo-ref: 445a22536b1a6c342cde0baa6fbca9e25092f94b

New ee-repo-ref: a3fefe85f5f2f52bb473fa47acc9efa8fd0b2206

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-19 05:26:18 +00:00
Ruben Fiszel 0f54ecd34c fix: revert git sync script bump 2026-05-19 05:17:34 +00:00
Ruben Fiszelandrubenfiszel 11c03ca14e chore(main): release 1.704.0 (#9210)
* chore(main): release 1.704.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-19 00:04:41 +00:00
Ruben Fiszel 0538412f1c fix(git-sync): bump default sync script to hub/28229 for extra_perms support (#9223) 2026-05-18 23:49:15 +00:00
Ruben FiszelandClaude Opus 4.7 ad5ec293b5 fix: reject path traversal in MCP endpoint path parameters (#9211)
* fix: reject path traversal in MCP endpoint path parameters

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: narrow MCP path-param validator to structural escapes only

Codex review: rejecting whitespace/`:`/`@` regressed legitimate
Windmill paths (app paths with spaces, email-style usernames like
u/admin@windmill.dev/...). These are ordinary path-segment data in an
absolute URL and cannot redirect the request. Reject only structural
escapes: control chars, `\`, `%`, `?`, `#`, and `.`/`..`/empty segments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:33:39 +00:00
Ruben FiszelandClaude Opus 4.7 8b7f7b37bd fix: don't fail flow on AlreadyCompleted after zombie restart (#9214)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:11:37 +00:00
centdix 49ebf6f8ba feat: add global chat selected context (#9216)
* feat: add global chat selected context

* refactor: store workspace context as references

* fix: refresh db context after global mode
2026-05-18 22:35:08 +00:00
hugocasa 29f4bada11 chore: watch WIN and GIT teams in webmux linear integration (#9215) 2026-05-18 22:33:12 +00:00
hugocasaandClaude Opus 4.7 4313225c7d oauth: add docusign provider (#9155)
Adds the Docusign Authorization Code OAuth entry. Used by the
Docusign integration in the windmill-integrations hub (PR #128).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:32:49 +00:00
centdix f965512c7a feat: add global ask user question tool (#9217)
* feat: add global ask user question tool

* feat: add keyboard navigation to user questions

* feat: simplify ask user question answers

* fix: disable strict mode for optional tool schemas

* fix: scope ask question keyboard events

* fix: clean up ask question display state
2026-05-18 21:36:52 +00:00
2e05bdd73a feat: show job status in favicon on the run page (#9206)
* feat: show job status in favicon on the run page

* test: cover getJobStatusKind favicon status mapping

* chore: remove favicon unit tests

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:43:53 +00:00
hugocasa 156eb0b045 fix: resolve absolute-path imports in monaco ts editor (#9213)
* fix: resolve absolute-path imports in monaco ts editor

* fix: dispose absolute-path extra libs on editor teardown and reset

* fix: skip late ata local-file callbacks after editor teardown
2026-05-18 16:45:19 +00:00
centdix fec4008696 fix: preserve ai reasoning content (#9208)
* fix: preserve ai reasoning content

* fix: avoid text-only reasoning replay

* feat: add deepseek ai eval models
2026-05-18 10:40:18 +00:00
centdix bd32c5f951 refactor: move openai-compatible proxy building (#9133)
* refactor: introduce ai proxy request types

* refactor: move openai-compatible proxy building
2026-05-18 10:24:16 +00:00
Ruben Fiszelandrubenfiszel 4e91f83b8f chore(main): release 1.703.3 (#9200)
* chore(main): release 1.703.3

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-18 09:09:50 +00:00
Ruben FiszelandClaude Opus 4.7 bd05bcadde fix: validate entrypoint override to prevent worker code injection (GHSA-wxjq-w5pj-jqhx) (#9204)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:01:05 +00:00
Ruben FiszelandClaude Opus 4.7 664edcdfb7 fix: enforce jobs:run scope on job preview and inline endpoints (#9198)
* fix: enforce jobs:run scope on job preview and inline endpoints

Preview/inline endpoints (run/preview, run/preview_bundle, run/preview_flow,
run/dynamic_select inline) execute arbitrary request-supplied code but only
checked folder/namespace read access, which is a no-op when path is null. A
token scoped to a specific script/flow could escape its scope and run any
code. Add a jobs:run scope check, matching other arbitrary-execution
endpoints. Advisory GHSA-vxc5-w28p-m9xw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: scope-check dynamic_select flow branch and inline preview

Address CI review: the dynamic_select Deployed{Flow} branch ran a deployed
flow's dynamic-select code without any scope check (only the Script branch
delegated to a scope-checked handler), and run_inline_preview_script executed
request-supplied code with no in-handler scope check. Add jobs:run:flows:{path}
to the flow branch and jobs:run to inline preview; correct the misleading
comment. Expand regression tests (preview_flow case, assert success for the
broad-token case). Advisory GHSA-vxc5-w28p-m9xw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: remove preview scope enforcement test after local validation

The regression test passed locally (3/3) and validated the fix end-to-end;
removed from the PR per maintainer preference. Advisory GHSA-vxc5-w28p-m9xw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:57:24 +00:00
Ruben FiszelandClaude Opus 4.7 9dbce4a8c4 ci: disable PDB generation in Windows backend tests (#9201)
The dev profile's split-debuginfo = "unpacked" is coerced to "packed" on
windows-msvc, so each test-binary link spawns the shared mspdbsrv.exe PDB
type server. With 12 parallel link jobs this races the type-server cap
(LNK1318 "LIMIT (12)") and exhausts the runner disk (LNK1180), recurringly
failing the Windows release CI. CI needs no debug info, so disable PDB
generation for the dev/test profiles in this job only.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:43:35 +00:00
Ruben Fiszel e1df6b45e9 chore: remove alpha/beta warnings from tested frontend features (#9196) 2026-05-17 14:41:10 +00:00
Ruben FiszelandClaude Opus 4.7 24eedef918 fix: constrain unauthenticated get_public_resource to app_theme resources (#9203)
* fix: constrain unauthenticated get_public_resource to app_theme resources

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: remove get_public_resource regression test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:36:51 +00:00
Ruben FiszelandClaude Opus 4.7 ab11c7747a fix: enforce folder ACL on flow run-by-version routes (#9202)
* fix: enforce folder ACL on flow run-by-version routes (GHSA-8mv7-hmrg-96xv)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: don't echo resolved flow path in version-route NotAuthorized (cubic P2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: remove GHSA-8mv7-hmrg-96xv regression test (verified locally pre-removal)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:36:24 +00:00
Ruben FiszelandClaude Opus 4.7 8bc2295b94 fix(mcp): validate oauth dynamic client registration redirect_uris (#9197)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:00:06 +00:00
Ruben Fiszelandrubenfiszel 20719b4731 chore(main): release 1.703.2 (#9195)
* chore(main): release 1.703.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-17 12:59:58 +00:00
Ruben FiszelandClaude Opus 4.7 f8467f38c8 fix: prevent cross-tenant DNS poisoning via writable /etc in nsjail (#9194)
* fix: bind /etc resolver files read-only in nsjail sandboxes

* docs(nsjail): explain why per-file /etc resolver binds are load-bearing

The explicit /etc/hosts, /etc/resolv.conf and /etc/hostname binds look
like removable duplication of the read-only /etc bind above them. They
are not: on Kubernetes those files are separate kubelet bind-mounts on
top of /etc and nsjail's read-only remount is non-recursive, so without
these shadow binds they stay writable and a job can persist cross-tenant
DNS poisoning for the pod lifetime. Comment guards against a future
"dedup cleanup" silently reintroducing the vulnerability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(nsjail): shorten the load-bearing-bind comment to 3 lines

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-17 12:54:38 +00:00
Ruben FiszelandClaude Opus 4.7 25172bdc28 sidebar rendering expanded-but-empty at sub-pixel widths near 768px (#9191)
* fix: sidebar menu rendering expanded-but-empty near 768px width

The desktop sidebar branch is gated by JS (`innerWidth < 768`), but its
width was set only via Tailwind `md:` classes (`@media (min-width:768px)`).
`window.innerWidth` rounds fractional viewport widths, so at e.g. 767.8px
JS rounds to 768 and renders the desktop sidebar, while the CSS media
query does not match and no width class applies — leaving the sidebar
shell expanded with no width/content. Drop the now-redundant `md:`
prefix so width tracks the JS branch decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: align content offset breakpoint with sidebar JS gate

The sidebar width now follows the JS innerWidth gate, but the main
content left-offset in AiChatLayout still used the `md:` CSS media
query, leaving the two breakpoints out of sync in the same sub-pixel
band. Pass an `isMobile` flag from the layout (mirroring the sidebar's
`innerWidth < 768` condition) and gate the content padding on it with
unprefixed classes so sidebar width and content offset always flip
together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:13:15 +00:00
Ruben Fiszelandrubenfiszel fa090f3081 chore(main): release 1.703.1 (#9182)
* chore(main): release 1.703.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-16 08:42:33 +00:00
Ruben FiszelandClaude Opus 4.7 dfeed9c5c2 fix: actionable error when a custom_path is taken by an app in another workspace (#9190)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:29:53 +00:00
52960ca30a fix: reset parent_hash in auto_parent when all versions at path are archived (#9172)
* fix: reset parent_hash in auto_parent when all versions at path are archived

* test: regression test for auto_parent with all versions archived

---------

Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-05-16 07:43:35 +00:00
Ruben Fiszel 302ce58e98 harden UI builder artifact bootstrap with verified pinned metadata (#9189)
* feat: harden UI builder artifact bootstrap with verified pinned metadata

* fix: emit tab-indented artifact json to match prettier config

* refactor: rewrite artifact json with node instead of python

* refactor: simplify bootstrap to flat script, drop test scaffolding
2026-05-16 07:40:29 +00:00
Ruben FiszelandClaude Opus 4.7 4e25954722 fix: don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs (#9188)
* fix: don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: impl std::error::Error for SsrfValidationError for anyhow callers

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 07:39:47 +00:00
Ruben FiszelandClaude Opus 4.7 81b5736106 fix: atomic bundle cache writes to prevent parallel cold-load race (#9186)
* fix: atomic bundle cache writes to prevent parallel cold-load race

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: trust-but-replace in atomic_publish_dir to never trust a stale partial cache dir

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: simplify atomic_publish_dir and add content-addressed rename-failure fallback

Revert the destroy-then-recreate dir swap (introduced concurrent-publisher
edge cases: spurious Err under a real herd, EACCES masking a stale partial)
back to a single atomic rename. Add the content-addressed exists-fallback to
atomic_write_file_bytes/atomic_copy_file so the loser of a publish race (and
Windows, where rename cannot replace an open/existing destination) treats the
already-published identical cache as success instead of failing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:04:16 +00:00
Ruben FiszelandClaude Opus 4.7 6a334e9a07 fix: detect S3 assets passed as SDK object arg in ts parser (#9181)
windmill-parser-ts-asset only recognized writeS3File/loadS3File when
the first arg was a bare 's3://...' string literal. The actual SDK
signature takes an S3Object ({ s3, storage? }) or 's3://bucket/key'
string, which every real script uses, so object-form writes/reads were
never detected as assets. Resolve the S3Object arg the same way the
runtime parseS3Object does, mapping { s3, storage } to s3://<storage>/
<key> and feeding it through parse_asset_syntax so the path matches the
// on s3:///… trigger form. Adds regression tests.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:19:55 +00:00
Blossom 85555542bf replace sync deprecation placeholder link (#9180) 2026-05-15 13:20:36 +00:00
Ruben Fiszelandrubenfiszel e3a3dbb89c chore(main): release 1.703.0 (#9170)
* chore(main): release 1.703.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-15 13:15:28 +00:00
Ruben Fiszel 8f95402850 use OPENAI_API_KEY for codex workflow 2026-05-15 13:15:02 +00:00
69b3141e03 fix: apply pip_local_dependencies filtering to deployed scripts with populated lockfiles (#9178)
* fix: apply pip_local_dependencies filtering to deployed scripts with populated lockfiles

* refactor: share pip_local_dependencies filtering helper, log ignored deps

* test: split pure filter core out for unit testing, cover #-preservation

---------

Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-05-15 12:46:37 +00:00
Ruben Fiszel b7bc9b44b4 chore(frontend): update vite to 8.0.13 (#9179) 2026-05-15 12:45:00 +00:00
e1819313e1 fix: aggregate wait time should target the true root job, not flow_innermost_root_job (#9177)
* fix: aggregate wait time should target the true root job, not flow_innermost_root_job

* refactor: reuse get_root_job_id helper for wait-time aggregation

Instead of duplicating the root_job → flow_innermost_root_job →
parent_job fallback chain inline, call the existing get_root_job_id()
helper (the same one used by push_next_flow_job) and filter out the
self-id case so standalone scripts still skip aggregate insertion.
Behaviorally identical, single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:24:29 +00:00
Ruben Fiszel d48d61cc79 feat(otel-tracing-proxy): configurable tracing MITM NO_PROXY hosts (#9169)
* feat(otel-tracing-proxy): configurable NO_PROXY hosts

* refactor(otel-tracing-proxy): NO_PROXY only governs job-side bypass

* fix(otel-tracing-proxy): restore empty NO_PROXY default

* test(otel-tracing-proxy): unit tests for NO_PROXY normalization

* fix(otel-tracing-proxy): gate normalize_no_proxy_hosts to EE features
2026-05-14 14:03:47 +00:00
Ruben Fiszel 1c05604e4c add .claude worktrees 2026-05-14 13:37:50 +00:00
Ruben Fiszel f414ffc484 fix: never mark failure/trigger/approval scripts as auto_kind=lib (#9168) 2026-05-14 13:29:56 +00:00
Ruben Fiszelandrubenfiszel 7f589a8c7d chore(main): release 1.702.1 (#9166)
* chore(main): release 1.702.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-14 10:54:35 +00:00
Ruben Fiszel 6637e00375 test(nativets): cover deno_telemetry init contract + http trace export E2E (#9165) 2026-05-14 10:54:19 +00:00
bf99283c33 fix(nativets): pass tracing-enabled OtelConfig to deno_telemetry::init (#9163)
* [ee] fix(nativets): pass tracing-enabled OtelConfig to deno_telemetry::init

* chore: update ee-repo-ref to 19a76a09ffb43649ee19e62d07e8b8a42d78757b

This commit updates the EE repository reference after PR #573 was merged in windmill-ee-private.

Previous ee-repo-ref: daf7ce31f192d64ca6b5602af626ab6453d2aff4

New ee-repo-ref: 19a76a09ffb43649ee19e62d07e8b8a42d78757b

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-14 10:46:43 +00:00
Ruben Fiszelandrubenfiszel 90f494975d chore(main): release 1.702.0 (#9160)
* chore(main): release 1.702.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-14 09:18:42 +00:00
Ruben Fiszel 5e909b2b4f feat(git-sync): sync extra_perms for flows/scripts/apps (#9162)
* feat(git-sync): sync extra_perms for flows/scripts/apps

* fix(git-sync): hash extra_perms, fix script no-op + up-to-date checks

* refactor(git-sync): route extra_perms through /acls/* instead of update endpoints

* fix(git-sync): dispatch raw_app perm changes via DeployedObject::RawApp

* fix(git-sync): wire applyExtraPermsDiff into pushRawApp + per-change logs

* feat(git-sync): opt-in tarball perms, no-op when yaml omits, audit logs, validation

* fix(git-sync): replace remaining bool literal in EE-only trigger export call

* fix(git-sync): raw_app /acls/* hits app table; refetch after create

* fix(git-sync): drop unnecessary post-deploy refetch (folder perms not merged)

* fix(git-sync): raw_app /acls/get; reject malformed local extra_perms

* audit: distinct raw_apps prefix so dashboards can split raw_app ACL events
2026-05-14 08:16:24 +00:00
Ruben Fiszel d870edc959 fix(mcp): sanitize and enrich nested resource schemas (#9158)
* fix(mcp): sanitize and enrich nested resource schemas

* fix(mcp): strip Windmill-internal keys inline during resource enrichment

* fix(mcp): strip resourceType unconditionally in schema sanitizer
2026-05-14 06:54:32 +00:00
Ruben FiszelandClaude Opus 4.7 e5286f4607 feat: include service accounts in instance settings users list (#9157)
* feat: include service accounts in instance settings users list

Service accounts (workspace-scoped, no password row) now appear in the
superadmin users list with a Bot icon, workspace badge, and a link to
manage them in the workspace settings. Role is locked to Operator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update sqlx offline cache

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: use composite key for users each block

Service accounts can share emails across workspaces, so key by email + workspace_id to avoid Svelte each_key_duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:46:48 +00:00
Ruben Fiszelandrubenfiszel 4f3a1e3109 chore(main): release 1.701.0 (#9131)
* chore(main): release 1.701.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-13 16:19:55 +00:00
849 changed files with 76627 additions and 16621 deletions
+41 -4
View File
@@ -55,18 +55,55 @@ The body MUST be explicit about what changed. Structure:
The harness/tooling that invoked the skill may add its own attribution trailer; the skill itself does not prescribe one.
## Screenshots (required for frontend changes)
If `git diff main...HEAD --name-only` matches `^frontend/`, the PR body **must** include
screenshots of the affected UI. Skip only when there is no visible UI effect (types,
tests, build config) — and say so in the body.
1. Verify the change in the browser (AGENTS.md → "Verifying Frontend Changes").
2. Screenshot each affected page with `mcp__playwright__browser_take_screenshot` (save to a file).
3. Host each image and get its Markdown embed by pushing to the public
`windmill-labs/agent-screenshots-internal` repo. **Pipe base64 through stdin**
passing it as `-f content=…` fails with `argument list too long` on real images:
```bash
REPO=windmill-labs/agent-screenshots-internal
IMG=screenshot.png # repeat per page
DEST="shots/$(git branch --show-current)/$(date +%s)-$(basename "$IMG")"
base64 -w0 "$IMG" | jq -Rs --arg m "add $DEST" '{message:$m, content:.}' \
| gh api -X PUT "repos/$REPO/contents/$DEST" --input - >/dev/null
echo "![$(basename "$IMG" .png)](https://raw.githubusercontent.com/$REPO/main/$DEST)"
```
Derive `$DEST` from the file name (as above) so distinct pages never collide — a
fixed name would make same-second uploads reuse one path, and the second `PUT`
then 422s (the Contents API needs the existing file's `sha` to overwrite).
4. Put the printed `![]()` lines under a `## Screenshots` heading in the PR body.
Requires `gh` (`repo` scope), `jq`, `base64` — all in the devShell. The host repo is
public (so the raw URLs render for reviewers without a token) and its history is
permanent — **never screenshot pages that show secrets or sensitive values** (workspace
variables, resource values, instance settings, OAuth/SMTP config); deleting the file
can't undo an accidental capture. (GitHub's drag-and-drop uploader needs a browser
session and can't be driven from a token.)
If `gh` can't push to the host repo (e.g. a CI token scoped only to `windmill`), do
**not** fail the PR or skip silently — hand the upload to the user, who has push access,
and continue once they confirm it's done.
## Execution Steps
1. Run `git status` to check for uncommitted changes
2. Run `git log main..HEAD --oneline` to see all commits in this branch
3. Run `git diff main...HEAD` to see the full diff against main
4. **Invoke the `local-review` skill** before creating the PR (`/local-review` in Claude Code, `$local-review` in Codex, `pi --skill local-review` / `/skill:local-review` in Pi). If issues are found, fix them and commit before proceeding. Do not skip this step.
5. Check if remote branch exists and is up to date:
5. **Screenshots for frontend changes**: if `git diff main...HEAD --name-only` matches `^frontend/`, capture and embed screenshots of the affected UI per "Screenshots" above before writing the PR body (skip only if there is no visible UI effect).
6. Check if remote branch exists and is up to date:
```bash
git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "no upstream"
```
6. Push to remote if needed: `git push -u origin HEAD`
7. Create draft PR using gh CLI:
7. Push to remote if needed: `git push -u origin HEAD`
8. Create draft PR using gh CLI:
```bash
gh pr create --draft --title "<type>: <description>" --body "$(cat <<'EOF'
## Summary
@@ -82,7 +119,7 @@ The harness/tooling that invoked the skill may add its own attribution trailer;
EOF
)"
```
8. Return the PR URL to the user
9. Return the PR URL to the user
## EE Companion PR (when `*_ee.rs` files were modified)
+4
View File
@@ -78,3 +78,7 @@ Use the Svelte MCP tools when working on Svelte code:
2. **get-documentation**: Fetch relevant sections based on use_cases
3. **svelte-autofixer**: MUST use on all Svelte code before finalizing — keep calling until no issues
4. **playground-link**: Only after user confirms and code was NOT written to project files
## Verifying in the Browser
After changing Svelte code, use the **Playwright MCP** (`mcp__playwright__*`) to drive the running frontend and confirm the change works. See AGENTS.md → "Verifying Frontend Changes" for the full flow. Use `playwright` (headless) on devboxes; `playwright-headed` when a display is available.
+18 -1
View File
@@ -16,6 +16,23 @@ command="$(echo "$input" | jq -r '.tool_input.command // empty')"
if [[ "$command" =~ ^git\ (push|reset|revert|checkout|merge|rebase|commit|add) ]]; then
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
if [[ "$branch" == "main" ]]; then
echo "BLOCK: You are on the main branch. Create or switch to a feature branch first."
echo "BLOCK: You are on the main branch. Create or switch to a feature branch first." >&2
exit 2
fi
fi
# Block force-push targeting main from any branch.
if [[ "$command" =~ ^git[[:space:]]+push([[:space:]]|$) ]]; then
has_force=false
if [[ "$command" =~ (--force([[:space:]]|=|$)|--force-with-lease|[[:space:]]-f([[:space:]]|$)) ]]; then
has_force=true
fi
# `+ref` refspec syntax is also a force push.
if [[ "$command" =~ [[:space:]]\+[A-Za-z] ]]; then
has_force=true
fi
if $has_force && [[ "$command" =~ (^|[[:space:]:])\+?main([[:space:]]|$) ]]; then
echo "BLOCK: Force-push to main is not allowed via Claude. Run it yourself if you really mean to." >&2
exit 2
fi
fi
+26 -2
View File
@@ -44,7 +44,25 @@
"Bash(git merge:*)",
"Bash(git rebase:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
"Bash(git commit:*)",
"Read(/tmp/**)",
"Write(/tmp/**)",
"Edit(/tmp/**)",
"Bash(rm:/tmp/*)",
"Bash(rm:/tmp/**)",
"Bash(rmdir:/tmp/*)",
"Bash(mkdir:/tmp/*)",
"Bash(mkdir:/tmp/**)",
"Bash(cp:/tmp/*)",
"Bash(cp:/tmp/**)",
"Bash(mv:/tmp/*)",
"Bash(mv:/tmp/**)",
"Bash(touch:/tmp/*)",
"Bash(touch:/tmp/**)",
"Bash(chmod:/tmp/*)",
"Bash(chmod:/tmp/**)",
"Bash(tar * /tmp/*)",
"Bash(unzip * /tmp/*)"
],
"deny": [
"Read(.env)",
@@ -72,7 +90,13 @@
"Bash(chown:*)",
"Bash(truncate:*)",
"Bash(shred:*)",
"Bash(unlink:*)"
"Bash(unlink:*)",
"mcp__claude_ai_Stripe",
"mcp__claude_ai_Gmail",
"mcp__claude_ai_Google_Calendar",
"mcp__claude_ai_Google_Drive",
"mcp__claude_ai_Slack",
"mcp__claude_ai_Linear"
]
},
"enableAllProjectMcpServers": true,
+1 -1
View File
@@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
ENV GO_PATH=/usr/local/go/bin/go
# UV
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
ENV TZ=Etc/UTC
+1 -1
View File
@@ -7,7 +7,7 @@ VERSION=$1
echo "Updating versions to: $VERSION"
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/Cargo.toml
sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/cli/src/main.ts
sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/cli/src/core/constants.ts
sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/benchmarks/lib.ts
sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml
sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
+1 -1
View File
@@ -7,7 +7,7 @@ VERSION=$1
echo "Updating versions to: $VERSION"
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/Cargo.toml
sed -i -e "/^export const VERSION =/s/= .*/= \"$VERSION\";/" ${root_dirpath}/cli/src/main.ts
sed -i -e "/^export const VERSION =/s/= .*/= \"$VERSION\";/" ${root_dirpath}/cli/src/core/constants.ts
sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/benchmarks/lib.ts
sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml
sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
+36 -2
View File
@@ -74,7 +74,7 @@ jobs:
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.9.24"
version: "0.9.25"
- uses: shivammathur/setup-php@v2
with:
@@ -98,6 +98,21 @@ jobs:
vcpkg.exe install openssl:x64-windows-static
vcpkg.exe integrate install
- name: Free disk space (post-vcpkg)
shell: pwsh
run: |
# vcpkg leaves multi-GB of buildtrees/downloads after installing openssl;
# we only need the installed/ dir for linking.
$vcpkgRoot = $env:VCPKG_INSTALLATION_ROOT
foreach ($sub in @("buildtrees", "downloads", "packages")) {
$path = Join-Path $vcpkgRoot $sub
if (Test-Path $path) {
Write-Host "Removing $path"
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $path
}
}
Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize
- name: Get runtime paths
id: runtime-paths
shell: pwsh
@@ -119,6 +134,10 @@ jobs:
cargo build --release -p windmill_duckdb_ffi_internal
New-Item -ItemType Directory -Path ..\target\debug -Force
Copy-Item target\release\windmill_duckdb_ffi_internal.dll ..\target\debug\
# duckdb is bundled (~2GB of build artifacts); the DLL is the only
# thing we need from this excluded-crate target dir.
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue target
Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize
- name: Print runtime versions and env
shell: pwsh
@@ -136,6 +155,10 @@ jobs:
echo "USERPROFILE=$env:USERPROFILE"
echo "HOME=$env:HOME"
- name: Disk space before cargo test
shell: pwsh
run: Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize
- name: cargo test
working-directory: backend
timeout-minutes: 60
@@ -144,7 +167,18 @@ jobs:
RUST_LOG: "off"
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
# 16-vcpu runners with disabled PDB still hit LNK1180 ("insufficient
# disk space") at link time with 12 parallel link jobs: each test
# binary link spikes several hundred MB of transient I/O. Capping at
# 8 trades ~25% wall time for headroom on the ~75GB runner disk.
CARGO_BUILD_JOBS: 8
# backend/Cargo.toml sets split-debuginfo = "unpacked", which on
# windows-msvc is coerced to "packed": every test-binary link spawns
# the mspdbsrv.exe PDB type server and writes a large .pdb. CI needs
# no debug info, so disable PDB generation for the dev/test profiles
# here (avoids both LNK1318 type-server limit and PDB disk usage).
CARGO_PROFILE_DEV_SPLIT_DEBUGINFO: "off"
CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: "off"
# Tests' poll-time stack frames (deep nested async fn chains in
# debug builds) reach ~1.8MB. 4MB gives ~2x headroom against flaky
# overflows under parallel-test contention.
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
node-version: "20"
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.9.24"
version: "0.9.25"
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
+19
View File
@@ -0,0 +1,19 @@
name: Check fixture is empty
on:
push:
branches: [main]
paths:
- "fixtures/**"
pull_request:
paths:
- "fixtures/**"
jobs:
check-empty-fixture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ensure fixtures/cli-sync/ has no committed snapshot
run: bash fixtures/check-empty.sh
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
allowed_bots: 'windmill-internal-app[bot]'
trigger_phrase: '/plan'
claude_args: |
--model opus
--model claude-fable-5
--system-prompt "# Claude Planning Mode
You are operating in PLANNING MODE ONLY. Your role is to create detailed, structured plans without making any code changes.
+1 -1
View File
@@ -51,4 +51,4 @@ jobs:
}
claude_args: |
--allowedTools "Bash,WebFetch,WebSearch"
--model opus
--model claude-fable-5
+18 -6
View File
@@ -20,6 +20,8 @@ on:
type: string
default: ''
secrets:
OPENAI_API_KEY:
required: false
CODEX_AUTH_JSON:
required: false
WINDMILL_EE_PRIVATE_ACCESS:
@@ -60,13 +62,18 @@ jobs:
- name: Check Codex configuration
id: codex_config
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
run: |
if [ -n "$CODEX_AUTH_JSON" ]; then
if [ -n "$OPENAI_API_KEY" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "auth_mode=api_key" >> "$GITHUB_OUTPUT"
elif [ -n "$CODEX_AUTH_JSON" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "auth_mode=oauth_json" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "CODEX_AUTH_JSON is not configured; skipping Codex review."
echo "Codex auth is not configured; set OPENAI_API_KEY or CODEX_AUTH_JSON to enable Codex review."
fi
- name: Resolve PR metadata
@@ -169,9 +176,10 @@ jobs:
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
run: npm install --global @openai/codex@0.128.0
- name: Configure file-backed Codex auth
- name: Configure Codex auth
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
run: |
CODEX_HOME="$HOME/.codex"
@@ -181,9 +189,13 @@ jobs:
cat > "$CODEX_HOME/config.toml" <<'EOF'
cli_auth_credentials_store = "file"
EOF
printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json"
chmod 600 "$CODEX_HOME/auth.json"
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json"
if [ -n "$OPENAI_API_KEY" ]; then
printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key
else
printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json"
chmod 600 "$CODEX_HOME/auth.json"
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json"
fi
- name: Pre-fetch base and head refs for the PR
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+3 -1
View File
@@ -8,6 +8,7 @@ on:
- "backend/windmill-git-sync/**"
- "backend/windmill-api-integration-tests/tests/git_sync*"
- "backend/ee-repo-ref.txt"
- "backend/windmill-common/src/workspaces.rs"
- "integration_tests/test/git_sync_test.py"
- ".github/workflows/git-sync-test.yml"
pull_request:
@@ -16,6 +17,7 @@ on:
- "backend/windmill-git-sync/**"
- "backend/windmill-api-integration-tests/tests/git_sync*"
- "backend/ee-repo-ref.txt"
- "backend/windmill-common/src/workspaces.rs"
- "integration_tests/test/git_sync_test.py"
- ".github/workflows/git-sync-test.yml"
@@ -49,7 +51,7 @@ jobs:
echo "$CHANGED_FILES"
# Direct git sync file changes — always relevant
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Relevant: direct git sync file changes"
exit 0
+1 -1
View File
@@ -160,4 +160,4 @@ jobs:
${{ env.REVIEW_PROMPT }}
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
--model opus
--model claude-fable-5
+1
View File
@@ -100,6 +100,7 @@ jobs:
extra_prompt: ${{ needs.parse.outputs.extra_prompt }}
triggered_by: ${{ github.event.comment.user.login }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
@@ -1,126 +0,0 @@
name: Spawn Ephemeral Backend
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
description: "PR number"
required: true
type: number
jobs:
check-membership:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/spawnbackend')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/spawnbackend'))
uses: ./.github/workflows/check-org-membership.yml
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
spawn-backend:
needs: check-membership
# Only run on PR comments that contain /spawn-backend, or manual dispatch
if: |
github.event_name == 'workflow_dispatch' ||
(github.event.issue.pull_request && needs.check-membership.outputs.is_member == 'true')
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Get PR details
id: pr-details
uses: actions/github-script@v7
with:
script: |
const prNumber = context.eventName === 'workflow_dispatch'
? context.payload.inputs.pr_number
: context.issue.number;
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
// Get branch name and format it for Cloudflare Pages
// Replace '/' with '-' for the URL
const branchName = pr.data.head.ref;
const formattedBranch = branchName.replace(/\//g, '-');
const cfFrontendUrl = `https://${formattedBranch}.windmill.pages.dev`;
core.setOutput('commit_hash', pr.data.head.sha);
core.setOutput('pr_number', prNumber);
core.setOutput('branch_name', branchName);
core.setOutput('cf_frontend_url', cfFrontendUrl);
- name: Check manager URL
id: check-manager-url
run: |
if [ -z "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}" ]; then
echo "manager_url_set=false" >> $GITHUB_OUTPUT
else
echo "manager_url_set=true" >> $GITHUB_OUTPUT
fi
- name: Post error comment if manager not running
if: steps.check-manager-url.outputs.manager_url_set == 'false'
uses: actions/github-script@v7
with:
script: |
const prNumber = context.eventName === 'workflow_dispatch'
? Number(context.payload.inputs.pr_number)
: context.issue.number;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `❌ Manager URL not set (did you start the ephemeral backend manager?)\n\nThe ephemeral backend manager needs to be running to spawn backends. Please start the manager first.`
});
- name: Fail if manager not running
if: steps.check-manager-url.outputs.manager_url_set == 'false'
run: |
echo "Error: EPHEMERAL_BACKEND_QUEUE_URL secret is not set"
exit 1
- name: Trigger Windmill flow
if: steps.check-manager-url.outputs.manager_url_set == 'true'
id: trigger-flow
run: |
JOB_UUID=$(curl -s -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run/f/f/all/run_ephemeral_backend" \
-H "Authorization: Bearer ${{ secrets.WINDMILL_RUN_FLOW_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"manager_url": "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}",
"commit_hash": "${{ steps.pr-details.outputs.commit_hash }}",
"pr_number": ${{ steps.pr-details.outputs.pr_number }},
"cf_frontend_url": "${{ steps.pr-details.outputs.cf_frontend_url }}"
}' | tr -d '"')
echo "Job UUID: $JOB_UUID"
echo "job_uuid=$JOB_UUID" >> $GITHUB_OUTPUT
- name: Post comment with job link
if: steps.check-manager-url.outputs.manager_url_set == 'true'
uses: actions/github-script@v7
with:
script: |
const jobUuid = '${{ steps.trigger-flow.outputs.job_uuid }}';
const appUrl = `https://app.windmill.dev/public/windmill-labs/a106bad0256c1dfa7a4f9279c42b1a4b#${jobUuid}`;
const prNumber = context.eventName === 'workflow_dispatch'
? Number(context.payload.inputs.pr_number)
: context.issue.number;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `🚀 Spawning new ephemeral backend!\n\n${appUrl}`
});
+1
View File
@@ -20,6 +20,7 @@ rust-client/Cargo.toml
# Worktree-specific Claude Code settings (generated by scripts/worktree-env)
.claude/settings.local.json
.claude/worktrees/
# Symlinked cache directories (for git worktrees)
backend/target
+10
View File
@@ -3,6 +3,16 @@
"svelte": {
"type": "http",
"url": "https://mcp.svelte.dev/mcp"
},
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless"]
},
"playwright-headed": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium"]
}
}
}
+48
View File
@@ -47,6 +47,7 @@ profiles:
For this window specifically, backend is running on: ${BACKEND_PORT} and frontend is running on: ${FRONTEND_PORT}.
To connect to the database, use this connection string: ${DATABASE_URL}
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check.
For UI verification, use the Playwright MCP (`mcp__playwright__*`) — the `playwright` server is headless and works without a display. Navigate to http://localhost:${FRONTEND_PORT}, log in as admin@windmill.dev / changeme.
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
panes:
- id: agent
@@ -76,6 +77,7 @@ profiles:
On this window specifically, frontend is running on: ${FRONTEND_PORT}.
To connect to the database, use this connection string: ${DATABASE_URL}
Because we are running frontend with npm run dev, to verify your changes, just check the logs in the frontend pane. No need for npm run build.
For UI verification, use the Playwright MCP (`mcp__playwright__*`) — the `playwright` server is headless and works without a display. Navigate to http://localhost:${FRONTEND_PORT}, log in as admin@windmill.dev / changeme.
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
panes:
- id: agent
@@ -100,9 +102,55 @@ profiles:
integrations:
github:
autoRemoveOnMerge: true
linkedRepos:
- repo: windmill-labs/windmill-ee-private
alias: ee-private
dir: ../windmill-ee-private__worktrees
linear:
enabled: true
autoCreateWorktrees: true
watchTeams: [WIN,GIT]
oneshot:
systemPrompt: |
You are running in webmux ONESHOT mode.
# No interactive user
There is NO interactive user — nobody is watching the chat or will respond
to questions, approvals, or status checks. Any message asking the user to
review, approve, confirm, take a look, or "let you know" is wasted output:
it will not be answered.
# Your job
Take the task to its real conclusion without pausing:
1. Make the change.
2. Validate it (run the relevant tests, typecheck, build, or quick
manual check). For UI changes, drive the running frontend with
the Playwright MCP (`mcp__playwright__*`, headless) and confirm
the change works end-to-end before moving on.
3. Commit.
4. Push.
5. Open a pull request.
Only then are you done.
# Decisions
When something is ambiguous, pick the most reasonable default and proceed.
When you would normally ask "should I X or Y?", just pick one and continue
— note the choice in the PR description if it matters.
# PR readiness
Default to opening the PR as a draft. If you are highly confident in the
change — the scope is small and well-understood, validation passed
cleanly, and you would not change anything if a reviewer pushed back —
open the PR as ready-for-review directly (omit `--draft` when invoking
`gh pr create`, or call `gh pr ready <number>` after creation). Err on
the side of draft when validation was partial, the change touches
public APIs or shared infrastructure, or you made a non-obvious judgment
call.
# Ending your turn
Never end your turn with a question, a suggestion to "take a look", or a
request for approval. Stop only when the PR is open, or when you hit a
technical error you cannot recover from yourself (in which case clearly
state the blocker).
+24
View File
@@ -15,6 +15,7 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Backend patterns**: use the `rust-backend` skill when writing Rust code
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
- **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead.
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi.
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
@@ -29,6 +30,28 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Instance settings**: navigate to `/#superadmin-settings`
- **Migrations**: use `cargo sqlx migrate add -r <name>` from `backend/` to create new migrations (never generate timestamps manually)
## Verifying Frontend Changes
After modifying frontend code, drive the running dev server with the **Playwright MCP** to verify the change in a real browser — don't claim a UI change works without exercising it.
Two MCP servers are registered in `.mcp.json`:
- `playwright` — headless Chromium, default for devboxes (no display required)
- `playwright-headed` — windowed Chromium, when a display is available
**One-time setup:** run `npx playwright install chromium` to download the browser binary (Playwright won't fetch it automatically on first use).
Typical flow:
1. Ensure backend (`cargo run`) and frontend (`REMOTE=http://localhost:8000 npm run dev`) are running
2. `mcp__playwright__browser_navigate` to the relevant page (login at `admin@windmill.dev` / `changeme`)
3. `mcp__playwright__browser_snapshot` to inspect the accessibility tree (preferred over screenshots for reading the DOM)
4. `mcp__playwright__browser_click` / `browser_fill_form` / `browser_type` to interact
5. `mcp__playwright__browser_take_screenshot` for visual confirmation
6. `mcp__playwright__browser_console_messages` / `browser_network_requests` to surface errors
**Attach the screenshots to the PR.** For any change under `frontend/`, embed screenshots of the affected UI in the PR body — the `pr` skill requires this and carries the upload recipe.
If you cannot exercise a UI change (no dev server, etc.), say so explicitly rather than claiming success.
## Banned Patterns
### `$bindable(default_value)` on optional props
@@ -86,3 +109,4 @@ $NAV --root backend callees "X" # what does X call?
- Search for existing code to reuse before writing new code
- Follow established patterns in the codebase
- Keep changes focused — don't refactor beyond what's asked
- **Never attribute work to a specific customer, account, or "requested by a customer" in repo-tracked content** (PR descriptions, commit messages, code comments, docs). Describe changes by their technical motivation instead.
+495
View File
@@ -1,5 +1,500 @@
# Changelog
## [1.722.0](https://github.com/windmill-labs/windmill/compare/v1.721.0...v1.722.0) (2026-06-11)
### Features
* add reasoning effort control and thinking display to AI chat ([#9511](https://github.com/windmill-labs/windmill/issues/9511)) ([7f987e8](https://github.com/windmill-labs/windmill/commit/7f987e8c9807b72d9cc3901b6e4d02a24c423f50))
* **ai-chat:** collapse big pastes, cap input height, escape HTML ([#9487](https://github.com/windmill-labs/windmill/issues/9487)) ([365e204](https://github.com/windmill-labs/windmill/commit/365e20410ed528d5b4e967b64fb282bcc1e03ccd))
* **ai-chat:** quick access to AI prompt settings from chat ([#9508](https://github.com/windmill-labs/windmill/issues/9508)) ([b894f78](https://github.com/windmill-labs/windmill/commit/b894f783f183ab795eab6f57da8654275d9ad82d))
* **ai:** add list_runs and get_job_logs tools to global chat mode ([#9488](https://github.com/windmill-labs/windmill/issues/9488)) ([cfe5119](https://github.com/windmill-labs/windmill/commit/cfe51190356a9e922f6398dd875abc368accbd15))
* clear conflict error + force delete when reusing a fork workspace id ([#9499](https://github.com/windmill-labs/windmill/issues/9499)) ([fddabe9](https://github.com/windmill-labs/windmill/commit/fddabe9c5c6f178b4b09854dc11e50adafd44c87))
* **flow:** support worker tag override on AI agent steps ([#9513](https://github.com/windmill-labs/windmill/issues/9513)) ([a6a5600](https://github.com/windmill-labs/windmill/commit/a6a5600833063e36b35aac7f90dcbdcd3db7c627))
* folder-level label inheritance for scripts, flows and jobs ([#9524](https://github.com/windmill-labs/windmill/issues/9524)) ([765f50c](https://github.com/windmill-labs/windmill/commit/765f50c474f8abf76550664ed15418ddb3c0b221))
* **frontend:** show AI sessions in narrow-screen burger menu ([#9523](https://github.com/windmill-labs/windmill/issues/9523)) ([a8f1062](https://github.com/windmill-labs/windmill/commit/a8f1062f37228e7a8257aac1cd3295bd5084ff90))
* prefer idle worker pods on k8s autoscaling scale-in via pod-deletion-cost ([#9515](https://github.com/windmill-labs/windmill/issues/9515)) ([3c3f157](https://github.com/windmill-labs/windmill/commit/3c3f15722fd22713cc8baa744d9a81207943a33c))
* prompt browser confirmation on page exit with unsaved changes ([#9503](https://github.com/windmill-labs/windmill/issues/9503)) ([3119e16](https://github.com/windmill-labs/windmill/commit/3119e16ed8df0daec0e019ae2efb2176d7e9e953))
* **worker:** #ssh directive to run a bash script on a remote SSH host ([#9479](https://github.com/windmill-labs/windmill/issues/9479)) ([afddfe8](https://github.com/windmill-labs/windmill/commit/afddfe84452357b06f4fb815566d4c8269ceafbf))
* workspace protection rule to restrict anonymous app deployment ([#9509](https://github.com/windmill-labs/windmill/issues/9509)) ([cf9ad54](https://github.com/windmill-labs/windmill/commit/cf9ad54181d38c5d3c3aa05208e17d8d97eae4ef))
### Bug Fixes
* **cli:** include lock-relevant script content in lock cache key ([#9528](https://github.com/windmill-labs/windmill/issues/9528)) ([dc60e1a](https://github.com/windmill-labs/windmill/commit/dc60e1aa174f2a7616d2d4a37117a463ef25a9d6))
* **frontend:** allow copy/paste shortcuts inside ConfirmationModal ([#9505](https://github.com/windmill-labs/windmill/issues/9505)) ([7fc5340](https://github.com/windmill-labs/windmill/commit/7fc5340da39c049d059aa0ae15ceb6a98ca58053))
* **frontend:** clarify trigger filters match the message parsed as JSON ([#9516](https://github.com/windmill-labs/windmill/issues/9516)) ([0b17843](https://github.com/windmill-labs/windmill/commit/0b178437ce8cd8295be21c0c6077c39e957a3337))
* **frontend:** enable Apply button when env vars change in worker group config ([#9501](https://github.com/windmill-labs/windmill/issues/9501)) ([c80c6d8](https://github.com/windmill-labs/windmill/commit/c80c6d8fcdfc1de8fe841e9699452f67a25cad23))
* **frontend:** improve AI chat markdown and typing dots in dark mode ([#9497](https://github.com/windmill-labs/windmill/issues/9497)) ([4e86806](https://github.com/windmill-labs/windmill/commit/4e868062d4c22c5574149d35235b7ad1abe805f3))
* **frontend:** stop echoing draft values in global chat write tool results ([#9530](https://github.com/windmill-labs/windmill/issues/9530)) ([ce6e2f7](https://github.com/windmill-labs/windmill/commit/ce6e2f7ade25ca91c375f0de5f1be3f1c82ccb47))
* inherit container NO_PROXY into MITM tracing proxy job exclusions ([#9492](https://github.com/windmill-labs/windmill/issues/9492)) ([4c22e3b](https://github.com/windmill-labs/windmill/commit/4c22e3b712a74828cf654ea7d89aeab5b50cfbd7))
* make default chat model optional in AI settings ([#9514](https://github.com/windmill-labs/windmill/issues/9514)) ([1d43288](https://github.com/windmill-labs/windmill/commit/1d4328877fcc87352fb56de63f0570739d5a9dc4))
* **nsjail:** make ansible collections mount non-mandatory ([#9510](https://github.com/windmill-labs/windmill/issues/9510)) ([08da7a1](https://github.com/windmill-labs/windmill/commit/08da7a121b4b835500dfc2bd943c4bdce912c63e))
* **nsjail:** make ansible uv tools mount non-mandatory ([#9507](https://github.com/windmill-labs/windmill/issues/9507)) ([dc368a9](https://github.com/windmill-labs/windmill/commit/dc368a9669e812c593ad848266f645b6223501e6))
## [1.721.0](https://github.com/windmill-labs/windmill/compare/v1.720.0...v1.721.0) (2026-06-09)
### Features
* deployed↔draft compare + AI-session draft bar ([#9435](https://github.com/windmill-labs/windmill/issues/9435)) ([b0b330c](https://github.com/windmill-labs/windmill/commit/b0b330c7864d0159af4b0f17dbb3c09bd015145b))
### Bug Fixes
* **cli:** reconcile case-only path drift during sync on case-insensitive filesystems (WIN-2020) ([#9485](https://github.com/windmill-labs/windmill/issues/9485)) ([c258928](https://github.com/windmill-labs/windmill/commit/c258928ab62adc1327913c21556520dfd1e5c24c))
* drop archived items from fork compare (spurious 'not visible' warning) ([#9481](https://github.com/windmill-labs/windmill/issues/9481)) ([92c21bb](https://github.com/windmill-labs/windmill/commit/92c21bbe6586f3c285796a98692e976515a629d5))
* require auth to view approval details when user_auth_required ([#9482](https://github.com/windmill-labs/windmill/issues/9482)) ([5f41ddd](https://github.com/windmill-labs/windmill/commit/5f41ddd3a592bcd504f94fc99060ca5d79c36190))
## [1.720.0](https://github.com/windmill-labs/windmill/compare/v1.719.0...v1.720.0) (2026-06-08)
### Features
* allow private MCP server URLs ([#9470](https://github.com/windmill-labs/windmill/issues/9470)) ([3bc5800](https://github.com/windmill-labs/windmill/commit/3bc5800197db383ae6f708415701a4bdbe2e3345))
* **api:** add endpoint to update token label ([#9474](https://github.com/windmill-labs/windmill/issues/9474)) ([e8e0701](https://github.com/windmill-labs/windmill/commit/e8e0701a360d0614c4c5a74f6410ba6ac0638caa))
* **frontend:** use unified drill picker for AI chat @-mention dropdown ([#9159](https://github.com/windmill-labs/windmill/issues/9159)) ([64b089c](https://github.com/windmill-labs/windmill/commit/64b089cd23cca4601abb09f092a32becb80d9394))
### Bug Fixes
* center auth0/okta icons and respect currentColor ([#9457](https://github.com/windmill-labs/windmill/issues/9457)) ([5d0ef7d](https://github.com/windmill-labs/windmill/commit/5d0ef7dfd91b3021d125a1b34f81f0788f173786))
* **forks:** keep trigger/schedule operational state owned by the parent - WIN-2019 ([#9476](https://github.com/windmill-labs/windmill/issues/9476)) ([192574a](https://github.com/windmill-labs/windmill/commit/192574ab8f98d9521a232fc8a4935d407b00cb3a))
* **frontend:** respect forced column order for numeric column names ([#9463](https://github.com/windmill-labs/windmill/issues/9463)) ([44f5dd6](https://github.com/windmill-labs/windmill/commit/44f5dd6636d4b23aa55383b8b8abe4c2f73bc88d))
* **frontend:** use ban icon for canceled jobs instead of hourglass ([#9478](https://github.com/windmill-labs/windmill/issues/9478)) ([fa86c62](https://github.com/windmill-labs/windmill/commit/fa86c62b6600e7d47dadf4706d7002706333d919))
* gate native integration pickers behind non-operator check ([#9465](https://github.com/windmill-labs/windmill/issues/9465)) ([6156e23](https://github.com/windmill-labs/windmill/commit/6156e2372a785ccd0c6f29cb74e90bee69e76483))
* **oauth:** persist refreshed token through configured secret backend ([#9471](https://github.com/windmill-labs/windmill/issues/9471)) ([76c0d97](https://github.com/windmill-labs/windmill/commit/76c0d970a18bf28ddc48dd746570e73486542606))
* refresh session editor preview on breadcrumb target switch ([#9475](https://github.com/windmill-labs/windmill/issues/9475)) ([6d522b3](https://github.com/windmill-labs/windmill/commit/6d522b3989ace1f214bd049d910bc0d2a2a6893e))
## [1.719.0](https://github.com/windmill-labs/windmill/compare/v1.718.0...v1.719.0) (2026-06-06)
### Features
* **otel:** connect jobs to the inbound distributed trace ([#9456](https://github.com/windmill-labs/windmill/issues/9456)) ([fad1a54](https://github.com/windmill-labs/windmill/commit/fad1a549d95c00d0746a48163c4f95fc69733e1a))
### Bug Fixes
* authenticate slack callback payload with per-workspace hmac ([#9461](https://github.com/windmill-labs/windmill/issues/9461)) ([fbdf81b](https://github.com/windmill-labs/windmill/commit/fbdf81ba5f77d282c025360ecee14138dd4cb4a2))
* prevent token label collision bypassing job read access control ([#9462](https://github.com/windmill-labs/windmill/issues/9462)) ([e1e7af6](https://github.com/windmill-labs/windmill/commit/e1e7af6a25a44eb06b67332ce1efeae2a21e0c6d))
* **python:** escape reserved-keyword step ids in wrapper codegen ([#9460](https://github.com/windmill-labs/windmill/issues/9460)) ([6a15a9b](https://github.com/windmill-labs/windmill/commit/6a15a9b152ad20be4b5c3de6000516da231e41e0)), closes [#8893](https://github.com/windmill-labs/windmill/issues/8893)
## [1.718.0](https://github.com/windmill-labs/windmill/compare/v1.717.1...v1.718.0) (2026-06-05)
### Features
* **flows:** opt-in to include the stopping step's result in early-stop errors ([#9446](https://github.com/windmill-labs/windmill/issues/9446)) ([f2f0812](https://github.com/windmill-labs/windmill/commit/f2f0812a04c9256cfc8eba5e0dcf38d71d971410))
* make C# dotnet target framework configurable via DOTNET_TARGET_FRAMEWORK ([#9454](https://github.com/windmill-labs/windmill/issues/9454)) ([9a609bf](https://github.com/windmill-labs/windmill/commit/9a609bf08ac1b6157dbdfb827fc01e771d71262e))
* sandboxed daemonless container runtime via '# sandbox &lt;image&gt;' ([#9453](https://github.com/windmill-labs/windmill/issues/9453)) ([1727271](https://github.com/windmill-labs/windmill/commit/1727271e197b34026efeaf1b6561bb404a440baa))
* **sandbox:** pull/extract images with crane instead of podman ([#9455](https://github.com/windmill-labs/windmill/issues/9455)) ([7590b28](https://github.com/windmill-labs/windmill/commit/7590b281085afd1fc2774e8fb37a4c0af3aedbad))
### Bug Fixes
* distinguish canceled jobs in runs ([#9452](https://github.com/windmill-labs/windmill/issues/9452)) ([9067787](https://github.com/windmill-labs/windmill/commit/90677872f6185eb0c81e0e84a426a54653818457))
## [1.717.1](https://github.com/windmill-labs/windmill/compare/v1.717.0...v1.717.1) (2026-06-04)
### Bug Fixes
* invalidate relative-import cache when imported script changes ([#9443](https://github.com/windmill-labs/windmill/issues/9443)) ([f595787](https://github.com/windmill-labs/windmill/commit/f595787409a3fcda9278bbcf2cfcc80092f16460))
## [1.717.0](https://github.com/windmill-labs/windmill/compare/v1.716.0...v1.717.0) (2026-06-04)
### Features
* let flow AI chat create and edit sticky notes ([#9412](https://github.com/windmill-labs/windmill/issues/9412)) ([e4e0984](https://github.com/windmill-labs/windmill/commit/e4e0984e55afd3c73f1c365cd0608493a9fd87ed))
### Bug Fixes
* **cli:** push whole raw app instead of treating frontend files as scripts ([#9442](https://github.com/windmill-labs/windmill/issues/9442)) ([b5a6a1e](https://github.com/windmill-labs/windmill/commit/b5a6a1eeab663c2d6aaec2c89eab7a550cb0bb6b))
* read latest db draft for scripts/flows in global mode read tool ([#9441](https://github.com/windmill-labs/windmill/issues/9441)) ([819ba5e](https://github.com/windmill-labs/windmill/commit/819ba5e150ec9f5199919fbea50874fc156d0189))
## [1.716.0](https://github.com/windmill-labs/windmill/compare/v1.715.0...v1.716.0) (2026-06-03)
### Features
* add metadata generation model setting ([#9418](https://github.com/windmill-labs/windmill/issues/9418)) ([cf5fefb](https://github.com/windmill-labs/windmill/commit/cf5fefb521479170b9dc64b884630c4dac789931))
* auto-generate AI session names ([#9399](https://github.com/windmill-labs/windmill/issues/9399)) ([26b7270](https://github.com/windmill-labs/windmill/commit/26b727041830c9b741668a9ab73e2eb90c7cec74))
* support $f/ and $u/ import path aliases for scripts ([#9378](https://github.com/windmill-labs/windmill/issues/9378)) ([220cd35](https://github.com/windmill-labs/windmill/commit/220cd35cf799c42ebf588bc97a6d8e6f4e97c2e3))
* use metadata model for small AI tasks ([#9431](https://github.com/windmill-labs/windmill/issues/9431)) ([79178f6](https://github.com/windmill-labs/windmill/commit/79178f6f5a7c606a2e05677c6efcbdd84c608325))
### Bug Fixes
* **apps:** relock no longer reverts raw app to a stale version ([#9432](https://github.com/windmill-labs/windmill/issues/9432)) ([073857a](https://github.com/windmill-labs/windmill/commit/073857ac0a9ed54bdeac8f373f7c855fe34eb0ac))
* **security:** scope variable and resource value caches by caller identity ([#9427](https://github.com/windmill-labs/windmill/issues/9427)) ([0ba128a](https://github.com/windmill-labs/windmill/commit/0ba128afe797bd016da60563949ac3abbbfe1978))
## [1.715.0](https://github.com/windmill-labs/windmill/compare/v1.714.1...v1.715.0) (2026-06-03)
### Features
* **frontend:** add rebuild dependency map button to workspace settings ([#9424](https://github.com/windmill-labs/windmill/issues/9424)) ([3b2e748](https://github.com/windmill-labs/windmill/commit/3b2e748daf0a8ec4447c30423068df803f3f9ca2))
### Bug Fixes
* **auth:** filter script/flow listings by token scope (GHSA-2ppx-66jv-wpw5) ([#9426](https://github.com/windmill-labs/windmill/issues/9426)) ([7edf3f0](https://github.com/windmill-labs/windmill/commit/7edf3f02122e20fde1e95e0252e7bda641075326))
* **backend:** authorize single-job read endpoints by job/flow visibility ([#9416](https://github.com/windmill-labs/windmill/issues/9416)) ([89a7a37](https://github.com/windmill-labs/windmill/commit/89a7a377764086911db18252f2478f42f0e1e3ea))
* **mcp:** resolve MCP resource token via caller RLS + SSRF-guard url ([#9428](https://github.com/windmill-labs/windmill/issues/9428)) ([8053266](https://github.com/windmill-labs/windmill/commit/8053266f88bd4c94fc86278412df5a0beeed5e77))
* **nsjail:** precompile python stdlib + raise download rlimit_as ([#9429](https://github.com/windmill-labs/windmill/issues/9429)) ([7031744](https://github.com/windmill-labs/windmill/commit/7031744a199f0bf8b8e35043afa959977e5ecdbd))
* omit temperature for gpt-5+ and o-series models on all providers ([#9422](https://github.com/windmill-labs/windmill/issues/9422)) ([11d1ad9](https://github.com/windmill-labs/windmill/commit/11d1ad9a872d2ec2f14cde35708c84a0c7bdc172))
## [1.714.1](https://github.com/windmill-labs/windmill/compare/v1.714.0...v1.714.1) (2026-06-02)
### Bug Fixes
* **backend:** route //native TypeScript previews to native workers (WIN-2007) ([#9407](https://github.com/windmill-labs/windmill/issues/9407)) ([73edebc](https://github.com/windmill-labs/windmill/commit/73edebc833a981488a8ea116f4f13c020a011a6f))
* **nsjail:** raise python download fd limit for --compile-bytecode (WIN-2009) ([#9414](https://github.com/windmill-labs/windmill/issues/9414)) ([9e6559a](https://github.com/windmill-labs/windmill/commit/9e6559a6f688cc8d982277b19920219ea6d0fd8e))
* **triggers:** prevent Zoom challenge handler from being used as a signing oracle ([#9413](https://github.com/windmill-labs/windmill/issues/9413)) ([ab2a15b](https://github.com/windmill-labs/windmill/commit/ab2a15b2a859096eabde718bf6e60289ae187118))
## [1.714.0](https://github.com/windmill-labs/windmill/compare/v1.713.1...v1.714.0) (2026-06-02)
### Features
* add global ai chat test tools ([#9391](https://github.com/windmill-labs/windmill/issues/9391)) ([5c20d6b](https://github.com/windmill-labs/windmill/commit/5c20d6b4f79f2ccc1987ce7fdaf74e6b8f697846))
* add workspace datatable tools to global AI chat mode ([#9395](https://github.com/windmill-labs/windmill/issues/9395)) ([943ef6e](https://github.com/windmill-labs/windmill/commit/943ef6eb2089f4b744cfa7945ce47f7f3b361ec7))
* **flow-ai:** constrain flow-group colors to the NoteColor palette ([#9343](https://github.com/windmill-labs/windmill/issues/9343)) ([e4213c1](https://github.com/windmill-labs/windmill/commit/e4213c1ab8c448f492f372580f5c9df37e33fffc))
* **frontend:** surface local drafts in drawer editors with an unsaved-changes banner ([#9335](https://github.com/windmill-labs/windmill/issues/9335)) ([075faab](https://github.com/windmill-labs/windmill/commit/075faabf3bba16a10a02ae3973008e5a13473085))
* handle CTRL_BREAK_EVENT for graceful shutdown on Windows ([#9400](https://github.com/windmill-labs/windmill/issues/9400)) ([2e14456](https://github.com/windmill-labs/windmill/commit/2e1445616a412c5112ad2247b4087c7ddc218845))
* refine ask-user-question chat display and keyboard nav ([#9392](https://github.com/windmill-labs/windmill/issues/9392)) ([1275487](https://github.com/windmill-labs/windmill/commit/1275487f028d4c74a9eeb18981ed05c225505be0))
* sessions page with isolated AI chat + flow editor ([#9034](https://github.com/windmill-labs/windmill/issues/9034)) ([eadeac2](https://github.com/windmill-labs/windmill/commit/eadeac248bd022c2796cfe638eb617c6143b8fc4))
### Bug Fixes
* **cli:** make encryption key push non-interactive-safe + add --skip-reencrypt-on-key-change ([#9402](https://github.com/windmill-labs/windmill/issues/9402)) ([e356bb1](https://github.com/windmill-labs/windmill/commit/e356bb1f5df92eca3fbb0ca2114b9f4c32d4c496))
* **cli:** stop git-sync promotion deploys from dropping triggers/schedules ([#9403](https://github.com/windmill-labs/windmill/issues/9403)) ([24e3ef2](https://github.com/windmill-labs/windmill/commit/24e3ef27be8498fb820c228a52febf6a0a91b487))
* **frontend:** align Monaco editor font size with text-xs ([#9161](https://github.com/windmill-labs/windmill/issues/9161)) ([de76668](https://github.com/windmill-labs/windmill/commit/de76668c10c04abe8771a8ca7bba7b2259819a1c))
* resolve username rename failing on apps with runnable deps ([#9401](https://github.com/windmill-labs/windmill/issues/9401)) ([e8ad53d](https://github.com/windmill-labs/windmill/commit/e8ad53dae92597f5a1a8b76f38a7d8c24f578a47))
### Performance Improvements
* **python:** add --compile-bytecode to uv pip install ([#9393](https://github.com/windmill-labs/windmill/issues/9393)) ([c19441b](https://github.com/windmill-labs/windmill/commit/c19441bc8cb2da064e4ad44d77dc04ab8bbb22ec))
## [1.713.1](https://github.com/windmill-labs/windmill/compare/v1.713.0...v1.713.1) (2026-06-01)
### Bug Fixes
* **api:** handle multi-version scripts when removing granular ACL ([#9388](https://github.com/windmill-labs/windmill/issues/9388)) ([9d9c503](https://github.com/windmill-labs/windmill/commit/9d9c5038ce8b0016320a670c434ef9063cb40441))
## [1.713.0](https://github.com/windmill-labs/windmill/compare/v1.712.0...v1.713.0) (2026-05-31)
### Features
* **flows:** preserve step/subflow worker tags under a custom-tagged flow ([#9375](https://github.com/windmill-labs/windmill/issues/9375)) ([f0301b1](https://github.com/windmill-labs/windmill/commit/f0301b1605cee5fba4024803555333e6fa5c40ee))
* **oauth:** support per-provider sandbox URLs ([#9358](https://github.com/windmill-labs/windmill/issues/9358)) ([2bf11dc](https://github.com/windmill-labs/windmill/commit/2bf11dcb15540c538ea2ac3cf70dcbe589060b4e))
### Bug Fixes
* **ai:** validate token_url for SSRF in OAuth credentials flow ([#9385](https://github.com/windmill-labs/windmill/issues/9385)) ([4b06881](https://github.com/windmill-labs/windmill/commit/4b06881918b76c5a411cc70b318e46efcc1393a7))
* **api:** authorize and harden log-file reading endpoints ([#9368](https://github.com/windmill-labs/windmill/issues/9368)) ([bb90f4c](https://github.com/windmill-labs/windmill/commit/bb90f4ce83a0e60af219b11c12ab4fe1d13f47a4))
* **apps:** make public apps opt into cross-origin isolation via wm_coep (GIT-884) ([#9374](https://github.com/windmill-labs/windmill/issues/9374)) ([2c0c2c4](https://github.com/windmill-labs/windmill/commit/2c0c2c467f163cd24c14c7be2db07af9cf2ce020))
* **auth:** enforce monotonic privilege on user token lifecycle endpoints ([#9371](https://github.com/windmill-labs/windmill/issues/9371)) ([2ddf93d](https://github.com/windmill-labs/windmill/commit/2ddf93de96622b2a1b2b6f59398a7a1f59360efd))
* batch encryption-key rotation into one git-sync job ([#9355](https://github.com/windmill-labs/windmill/issues/9355)) ([04a0897](https://github.com/windmill-labs/windmill/commit/04a08976aec4ba9b0516350316df303e9f96bfd3))
* **cli:** preserve user drafts on sync push and permissioned-as ([#9381](https://github.com/windmill-labs/windmill/issues/9381)) ([b0c3b01](https://github.com/windmill-labs/windmill/commit/b0c3b01d31b0ab3a6566e1f5fec60e3e230cfadb))
* **frontend:** sanitize user markdown to prevent stored XSS ([#9386](https://github.com/windmill-labs/windmill/issues/9386)) ([def01b8](https://github.com/windmill-labs/windmill/commit/def01b8ff6f331cc36ce02b947adc31c766042c4))
* **security:** re-pin cached hub scripts to CVE-patched versions (+ HUB_BASE_URL override for cache mode) ([#9387](https://github.com/windmill-labs/windmill/issues/9387)) ([edf340c](https://github.com/windmill-labs/windmill/commit/edf340c4d4f18b16b142cb7deb67afa586f10946))
## [1.712.0](https://github.com/windmill-labs/windmill/compare/v1.711.0...v1.712.0) (2026-05-28)
### Features
* add deepseek fim support ([#9365](https://github.com/windmill-labs/windmill/issues/9365)) ([2553fbf](https://github.com/windmill-labs/windmill/commit/2553fbfe31417bd985e7994eac695bf918f97ce2))
* deploy raw apps from global chat ([#9349](https://github.com/windmill-labs/windmill/issues/9349)) ([dec58e6](https://github.com/windmill-labs/windmill/commit/dec58e6c4f55062b42a752c43c89ef05903e713a))
* inject active editor into global chat ([#9361](https://github.com/windmill-labs/windmill/issues/9361)) ([9e7eaf3](https://github.com/windmill-labs/windmill/commit/9e7eaf36847ad3a004ec84e8b7d4784771b7b451))
* **queue:** duration-weighted fairness admission ([#9334](https://github.com/windmill-labs/windmill/issues/9334)) ([045d120](https://github.com/windmill-labs/windmill/commit/045d12043e7c99830ef90bc0da798c94e2094711))
* warn when custom instance db is shared across workspaces ([#9359](https://github.com/windmill-labs/windmill/issues/9359)) ([a9e5140](https://github.com/windmill-labs/windmill/commit/a9e514099585e5ee72df21bd551a223cceb20fb0))
### Bug Fixes
* **cli:** redact encryption_key diff in stdout by default ([#9347](https://github.com/windmill-labs/windmill/issues/9347)) ([88056f8](https://github.com/windmill-labs/windmill/commit/88056f8d4c91c1d14d85a08851ecf0bd97e2260d))
* **cli:** stop re-prompting on wmill refresh prompts ([#9357](https://github.com/windmill-labs/windmill/issues/9357)) ([c2b5ba8](https://github.com/windmill-labs/windmill/commit/c2b5ba8871abbbcff6de69c90e2f09fee70586c1))
* **frontend:** close other sidebar menus when hovering Help ([#9354](https://github.com/windmill-labs/windmill/issues/9354)) ([da882c5](https://github.com/windmill-labs/windmill/commit/da882c54b21e3eaf2c1d1abccd0996b243d96dce))
* **frontend:** prevent duplicate asset node ids crashing flow graph ([#9367](https://github.com/windmill-labs/windmill/issues/9367)) ([9a659b6](https://github.com/windmill-labs/windmill/commit/9a659b636d713ee8fdfbdad41c58bb3d7c79e0d9))
* **frontend:** prevent MultiSelect crash on undefined value ([#9364](https://github.com/windmill-labs/windmill/issues/9364)) ([aea0061](https://github.com/windmill-labs/windmill/commit/aea00611c41379be2afdad0eedd608c9537d03f7))
* **git-sync:** publish fork branch on only_create_branch from the CLI ([#9366](https://github.com/windmill-labs/windmill/issues/9366)) ([2fdc51e](https://github.com/windmill-labs/windmill/commit/2fdc51e62985fc755884436130bdd58e294247c8))
* infer script arg schema when deploying via AI chat ([#9356](https://github.com/windmill-labs/windmill/issues/9356)) ([4efc372](https://github.com/windmill-labs/windmill/commit/4efc37212a98571214aba135b0fbb10dc263fd4f))
* **monitor:** cleanup stale server_heartbeat background_task_state rows ([#9338](https://github.com/windmill-labs/windmill/issues/9338)) ([59ab038](https://github.com/windmill-labs/windmill/commit/59ab038d7718d8a4c25efa5928f42e1393ebbf40))
## [1.711.0](https://github.com/windmill-labs/windmill/compare/v1.710.1...v1.711.0) (2026-05-26)
### Features
* **cli:** add object-storage commands and flow test-step ([#9326](https://github.com/windmill-labs/windmill/issues/9326)) ([36f574f](https://github.com/windmill-labs/windmill/commit/36f574ff951198a4d40ee068a27d74c41ce32154))
### Bug Fixes
* **cli:** handle __flow suffix when deriving the flow's Windmill path ([#9333](https://github.com/windmill-labs/windmill/issues/9333)) ([6f77034](https://github.com/windmill-labs/windmill/commit/6f770346fb330997a836c39fba347df4c088a83c))
* **queue:** duration-weighted workspace fairness signal ([#9329](https://github.com/windmill-labs/windmill/issues/9329)) ([42d2121](https://github.com/windmill-labs/windmill/commit/42d2121af925de50f549ecb72ffb5132f5c41079))
## [1.710.1](https://github.com/windmill-labs/windmill/compare/v1.710.0...v1.710.1) (2026-05-26)
### Bug Fixes
* improve workspace fairness ([896add0](https://github.com/windmill-labs/windmill/commit/896add0350f4de31f5674d6be0907a582c5ec17e))
## [1.710.0](https://github.com/windmill-labs/windmill/compare/v1.709.0...v1.710.0) (2026-05-26)
### Features
* **queue:** stochastic admission + EE availability of workspace fairness algorithm ([#9321](https://github.com/windmill-labs/windmill/issues/9321)) ([8bf7fd2](https://github.com/windmill-labs/windmill/commit/8bf7fd2c921c48861b71731a085b18ea8f72fb68))
### Bug Fixes
* **websocket-trigger:** honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY ([#9324](https://github.com/windmill-labs/windmill/issues/9324)) ([6f36316](https://github.com/windmill-labs/windmill/commit/6f363163df9cd15f5af7d56cf34a01b70d236830))
## [1.709.0](https://github.com/windmill-labs/windmill/compare/v1.708.0...v1.709.0) (2026-05-25)
### Features
* add copy button to Path component ([#9311](https://github.com/windmill-labs/windmill/issues/9311)) ([98bd5e7](https://github.com/windmill-labs/windmill/commit/98bd5e7f2a437b8b534028838b6ed0d7c59f7011))
* **ai-chat:** align footer bar + DropdownV2 mode/autonomy selectors ([#9308](https://github.com/windmill-labs/windmill/issues/9308)) ([2f50e8b](https://github.com/windmill-labs/windmill/commit/2f50e8bab0b5ae9ae297c79abfe96df441f405e2))
* **ai-chat:** expand chat question answers ([#9310](https://github.com/windmill-labs/windmill/issues/9310)) ([3f219ae](https://github.com/windmill-labs/windmill/commit/3f219aed98d93158aefce01bb51ed12dcb4711a1))
* plug global chat drafts into userdraft ([#9291](https://github.com/windmill-labs/windmill/issues/9291)) ([1eef531](https://github.com/windmill-labs/windmill/commit/1eef53170b1b2afb75b9812e33787d1f28cf50dd))
* **raw_apps:** surface UI Builder build errors over the preview pane ([#9316](https://github.com/windmill-labs/windmill/issues/9316)) ([90a196d](https://github.com/windmill-labs/windmill/commit/90a196d8d81993ffc2377d7088ab98f7b0f5ddcc))
* **raw_apps:** tab-based editor surface with split-with-preview ([#9273](https://github.com/windmill-labs/windmill/issues/9273)) ([368e677](https://github.com/windmill-labs/windmill/commit/368e6774194a58058f28d1b4a42f8f4a7ec4ab63))
* **service-accounts:** allow choosing role at creation time ([#9307](https://github.com/windmill-labs/windmill/issues/9307)) ([b125eca](https://github.com/windmill-labs/windmill/commit/b125eca7628b07c071bd102b161d389259fd6c62))
### Bug Fixes
* **auth:** filter resource/variable listings by token scope (WIN-1981) ([#9302](https://github.com/windmill-labs/windmill/issues/9302)) ([b5a0d46](https://github.com/windmill-labs/windmill/commit/b5a0d46695fdfe692d64573d1cfa06511e3b33f5))
* **jobs:** authorization bypass in only_result job updates (WIN-1980) ([#9301](https://github.com/windmill-labs/windmill/issues/9301)) ([108a88a](https://github.com/windmill-labs/windmill/commit/108a88a1801548c8570d56aa3e1eb80246367bf4))
## [1.708.0](https://github.com/windmill-labs/windmill/compare/v1.707.0...v1.708.0) (2026-05-24)
### Features
* **queue:** per-workspace fairness cap on the shared cloud worker pool ([#9303](https://github.com/windmill-labs/windmill/issues/9303)) ([de2e243](https://github.com/windmill-labs/windmill/commit/de2e243313ee34348675dec600cb412b475d1b4b))
## [1.707.0](https://github.com/windmill-labs/windmill/compare/v1.706.1...v1.707.0) (2026-05-22)
### Features
* add wmill job rerun subcommand ([#9275](https://github.com/windmill-labs/windmill/issues/9275)) ([e0ffea2](https://github.com/windmill-labs/windmill/commit/e0ffea2deb5acf30815edd3669f4fc4c818b6e19))
* **github-app:** hide cloud-only UI on self-managed + admin assignment UI ([#9299](https://github.com/windmill-labs/windmill/issues/9299)) ([dcee8cc](https://github.com/windmill-labs/windmill/commit/dcee8cc0d3dd71c3a12f1720e3ce4eb86cdacf4f))
* **typescript-client:** add deleteS3File + optional workspace arg on S3 helpers ([#9300](https://github.com/windmill-labs/windmill/issues/9300)) ([daab561](https://github.com/windmill-labs/windmill/commit/daab561ec0763468d93e42e8f7f0796dc77be74d))
### Bug Fixes
* **auth:** tighten token-owner fallback for unscoped tokens (WIN-1978) ([#9293](https://github.com/windmill-labs/windmill/issues/9293)) ([7003998](https://github.com/windmill-labs/windmill/commit/7003998a575d76c272c6abd0789a1d1f7b722076))
* **cli:** wmill sync pull updates wmill-lock.yaml for raw apps ([#9289](https://github.com/windmill-labs/windmill/issues/9289)) ([486e5f9](https://github.com/windmill-labs/windmill/commit/486e5f947b1649c17d32e3b214c50d4be701a4e8))
* flow recording teardown crash + rename package to @windmill-labs/components ([#9288](https://github.com/windmill-labs/windmill/issues/9288)) ([13a2fae](https://github.com/windmill-labs/windmill/commit/13a2fae745ba4862006db5ee0811475c1d27fd1d))
* **flows:** restore Variables and Resources in flow editor prop picker ([#9290](https://github.com/windmill-labs/windmill/issues/9290)) ([5566c7b](https://github.com/windmill-labs/windmill/commit/5566c7b3ff2d5a6b15cb9187aa15ce1c7245b3fb))
* **ResourceEditor:** don't reset state when `selected` reverts to undefined ([#9295](https://github.com/windmill-labs/windmill/issues/9295)) ([1f2d2c1](https://github.com/windmill-labs/windmill/commit/1f2d2c11493db20b87615d41c21e5e1c35564739))
* **secret-backend:** pass DB to Vault migrations + show failure details ([#9292](https://github.com/windmill-labs/windmill/issues/9292)) ([ace2291](https://github.com/windmill-labs/windmill/commit/ace22910c40585a6a2c9abd0c46f7e5e0214e78e))
## [1.706.1](https://github.com/windmill-labs/windmill/compare/v1.706.0...v1.706.1) (2026-05-22)
### Bug Fixes
* fork compare visibility for non-admins and stale-token superadmins ([#9283](https://github.com/windmill-labs/windmill/issues/9283)) ([8272244](https://github.com/windmill-labs/windmill/commit/82722449e79da0b4b0ad4142aec7e7965e9ff236))
* **git-sync:** bump to hub/28234 with stateless gpg.program wrapper (WIN-1974) ([#9282](https://github.com/windmill-labs/windmill/issues/9282)) ([89a2f07](https://github.com/windmill-labs/windmill/commit/89a2f07218818b95238b4a4484deab3138099672))
* **nsjail:** gate unix-symlink test behind cfg(unix) for Windows build ([#9280](https://github.com/windmill-labs/windmill/issues/9280)) ([72e2c3a](https://github.com/windmill-labs/windmill/commit/72e2c3a6b3e0cb0f5bddf8291ae18bb8cf55ec28))
## [1.706.0](https://github.com/windmill-labs/windmill/compare/v1.705.0...v1.706.0) (2026-05-21)
### Features
* add userdraft listing primitives ([#9268](https://github.com/windmill-labs/windmill/issues/9268)) ([d0ee697](https://github.com/windmill-labs/windmill/commit/d0ee697e8b8de58085ea0b2ecde1af2b2441428d))
* add UV_PYTHON_INSTALL_MIRROR env and instance setting ([#9271](https://github.com/windmill-labs/windmill/issues/9271)) ([1169371](https://github.com/windmill-labs/windmill/commit/1169371d4885bdc18c76d03c6caae71f0e440235))
* add yolo mode for ai chat tools ([#9258](https://github.com/windmill-labs/windmill/issues/9258)) ([ac26aa4](https://github.com/windmill-labs/windmill/commit/ac26aa4e4c7cc2d493f136b59738c0708803cc6d))
* CLI datatable serve / psql ([#9267](https://github.com/windmill-labs/windmill/issues/9267)) ([28c8b5c](https://github.com/windmill-labs/windmill/commit/28c8b5c60fd46f961ae11b363b9be834fad6ee68))
* **cli:** add `wmill init prompts` and custom override slot ([#9266](https://github.com/windmill-labs/windmill/issues/9266)) ([1ba8ed8](https://github.com/windmill-labs/windmill/commit/1ba8ed8abd827313ce0f7728d9f84357417206ee))
* **nsjail:** optional disk-backed /tmp via instance setting ([#9272](https://github.com/windmill-labs/windmill/issues/9272)) ([b656dc6](https://github.com/windmill-labs/windmill/commit/b656dc6cdc8c50ef9740240447f119cceed18547))
### Bug Fixes
* **ai:** enforce RLS and scope check on user-supplied X-Resource-Path ([#9276](https://github.com/windmill-labs/windmill/issues/9276)) ([0692b97](https://github.com/windmill-labs/windmill/commit/0692b97c8a3818549d7050ea3e057e9cbf1ddb44))
* **debugger:** add non-root user support to Dockerfile ([#9277](https://github.com/windmill-labs/windmill/issues/9277)) ([0bdb6a9](https://github.com/windmill-labs/windmill/commit/0bdb6a9d5d5fb28a27af1b6eda9fde7172308faf))
* **indexer:** tell admins when ingress routes search to wrong pod ([#9274](https://github.com/windmill-labs/windmill/issues/9274)) ([d29a561](https://github.com/windmill-labs/windmill/commit/d29a5612fcd17eb4197468289e955a1209127cc1))
## [1.705.0](https://github.com/windmill-labs/windmill/compare/v1.704.1...v1.705.0) (2026-05-20)
### Features
* add flow_user_state(key) to QuickJS input transform sandbox (WIN-1947) ([#9093](https://github.com/windmill-labs/windmill/issues/9093)) ([88c1493](https://github.com/windmill-labs/windmill/commit/88c149314576789f46feb5c7e1af3225b061f0c6))
* add wmill protection-rules pull/push CLI commands ([#9240](https://github.com/windmill-labs/windmill/issues/9240)) ([01bad16](https://github.com/windmill-labs/windmill/commit/01bad16c0cc40fa64b2a72ccb8ded487c729cf35))
* **chat:** visual redesign — input, streaming indicator, scroll polish ([#9232](https://github.com/windmill-labs/windmill/issues/9232)) ([31a0469](https://github.com/windmill-labs/windmill/commit/31a046973af960764ee4e153b68c020cbd4690ce))
* **chat:** waiting-for-user indicator + scroll-to-latest polish ([#9252](https://github.com/windmill-labs/windmill/issues/9252)) ([7909878](https://github.com/windmill-labs/windmill/commit/790987831380611b5bd19a760b0a5433492d7796))
* **cli:** add datatable and ducklake list/run commands ([#9257](https://github.com/windmill-labs/windmill/issues/9257)) ([1d04904](https://github.com/windmill-labs/windmill/commit/1d04904a47245062e50c2cc6362bbbb21a6987aa))
* **debug:** show ghost breakpoint and tooltip on gutter hover ([#9150](https://github.com/windmill-labs/windmill/issues/9150)) ([271f0cb](https://github.com/windmill-labs/windmill/commit/271f0cbd087851fca86ed1530618ddbf728f13f3))
* **editors:** responsive top-bars + collapsible raw-app sidebar ([#9237](https://github.com/windmill-labs/windmill/issues/9237)) ([b0ed270](https://github.com/windmill-labs/windmill/commit/b0ed27096d9e918e946cf8a8a04af8ff1892b50f))
* export audit logs to a dedicated object store folder ([#9207](https://github.com/windmill-labs/windmill/issues/9207)) ([ba6fb70](https://github.com/windmill-labs/windmill/commit/ba6fb7021b5a720bff8e86b4741031902cf1c267))
* **frontend:** new path component ([#9017](https://github.com/windmill-labs/windmill/issues/9017)) ([9c28bbf](https://github.com/windmill-labs/windmill/commit/9c28bbfd694a5047b4a8a9fe5cc2e54309f8f067))
* **frontend:** sync home search bar state to URL ([#9256](https://github.com/windmill-labs/windmill/issues/9256)) ([31b7810](https://github.com/windmill-labs/windmill/commit/31b781000e62384af6b8e1ba0172e45ac6ab591f))
* **git-sync:** hidden `sync git-deploy` owns wm_deploy branch + e2e regression tests ([#9230](https://github.com/windmill-labs/windmill/issues/9230)) ([07202fd](https://github.com/windmill-labs/windmill/commit/07202fd048c999c9d32f3feee94a08625050283d))
* **indexer:** observability for unavailable search index (WIN-1956) ([#9239](https://github.com/windmill-labs/windmill/issues/9239)) ([285a787](https://github.com/windmill-labs/windmill/commit/285a78752a23aa467f9a82868d784599793d3a1f))
* **nsjail:** make tmpfs size configurable via instance setting ([#9261](https://github.com/windmill-labs/windmill/issues/9261)) ([9111f89](https://github.com/windmill-labs/windmill/commit/9111f8908de82e9032a63711158dff9c6bca255b))
* open ai chat path links in drawers ([#9220](https://github.com/windmill-labs/windmill/issues/9220)) ([f6fcdb5](https://github.com/windmill-labs/windmill/commit/f6fcdb5599c28b4890d6f775f657bfafeea1d380))
* persistent in-editor drafts via UserDraft ([#9121](https://github.com/windmill-labs/windmill/issues/9121)) ([0f7dd86](https://github.com/windmill-labs/windmill/commit/0f7dd86e5c3a43bc62c4c0501efec34226b6e279))
* resolve relative imports from local content in script/flow preview ([#9233](https://github.com/windmill-labs/windmill/issues/9233)) ([2a780ad](https://github.com/windmill-labs/windmill/commit/2a780ad87af69358f241536697f694612fe92d93))
* **snowflake:** derive public key from private key when omitted (WIN-1959) ([#9251](https://github.com/windmill-labs/windmill/issues/9251)) ([aa12c66](https://github.com/windmill-labs/windmill/commit/aa12c66c25e68eefec22c213e8f228fd0699d8ce))
* **vault:** optional KV secret path prefix setting (WIN-1960) ([#9249](https://github.com/windmill-labs/windmill/issues/9249)) ([d08f72b](https://github.com/windmill-labs/windmill/commit/d08f72b3e1ef194b5d656cafa15bc88e8b6ba731))
### Bug Fixes
* **autoscaling:** count custom worker groups by row, divide only native by NUM_WORKERS ([#9255](https://github.com/windmill-labs/windmill/issues/9255)) ([76d949e](https://github.com/windmill-labs/windmill/commit/76d949e7bc30eb8cadfdc52e031fcdf5ad97d2ed))
* **autoscaling:** full-scale below min_workers on large backlog ([#9234](https://github.com/windmill-labs/windmill/issues/9234)) ([a4d59a8](https://github.com/windmill-labs/windmill/commit/a4d59a81dfb6fffbd185a3aa009eb90c160bf42b))
* bound resource/variable interpolation recursion depth (WIN-1957) ([#9243](https://github.com/windmill-labs/windmill/issues/9243)) ([26f3cbe](https://github.com/windmill-labs/windmill/commit/26f3cbef259e643c6d79be893701eec70b7c0501))
* cgroup-aware DuckDB memory_limit + allocator memory release ([#9245](https://github.com/windmill-labs/windmill/issues/9245)) ([0022112](https://github.com/windmill-labs/windmill/commit/00221128cbf0801a45bad40246e50beceaba0a7e))
* collapse successful ai tool details ([#9265](https://github.com/windmill-labs/windmill/issues/9265)) ([413404a](https://github.com/windmill-labs/windmill/commit/413404a788bbe6b5c9df387a2db3000ffec74083))
* early return should consider failure_module result ([#9241](https://github.com/windmill-labs/windmill/issues/9241)) ([2db1c0a](https://github.com/windmill-labs/windmill/commit/2db1c0a1fcfdcad94cae97dcffa090ffb91494f7))
* enable jemalloc background_thread to prevent worker RSS growth ([#9236](https://github.com/windmill-labs/windmill/issues/9236)) ([a974ff6](https://github.com/windmill-labs/windmill/commit/a974ff68e00278ccaf441b0567cd46e2b5067fdd))
* enforce auth guards on app component preview execution ([#9235](https://github.com/windmill-labs/windmill/issues/9235)) ([4b1bea8](https://github.com/windmill-labs/windmill/commit/4b1bea8aed51eb9e24940d89d984ce32f375ab0c))
* **flows:** flag noLogs jobs and lazily resolve them in log panel ([#9099](https://github.com/windmill-labs/windmill/issues/9099)) ([740a35b](https://github.com/windmill-labs/windmill/commit/740a35bf7b20f0bd8cb94c3d703dd353f0711b0a))
* **frontend:** flow progress bar for early-stop completion and error handler (WIN-1961) ([#9254](https://github.com/windmill-labs/windmill/issues/9254)) ([cc141ef](https://github.com/windmill-labs/windmill/commit/cc141effa3b1019f70f4b7230fe7ebc7017632a0))
* **frontend:** open customer portal in popup synchronously to bypass Safari blocker ([#9242](https://github.com/windmill-labs/windmill/issues/9242)) ([f51b51a](https://github.com/windmill-labs/windmill/commit/f51b51a9a1aee5183fa597cf93ff14fbaddaffa9))
* prevent undefined user flickering in multiplayer presence list ([#9231](https://github.com/windmill-labs/windmill/issues/9231)) ([8c1f6cc](https://github.com/windmill-labs/windmill/commit/8c1f6ccc5d22e657a83831eb5f37a9516cdec10a))
* **s3:** sandbox stored XSS via download response headers ([#9263](https://github.com/windmill-labs/windmill/issues/9263)) ([bb78b1c](https://github.com/windmill-labs/windmill/commit/bb78b1c06de5b73b951691460f81a3a2ec6e7f80))
* **saml:** preserve deep links from /a/[...path] across SAML round-trip ([#9259](https://github.com/windmill-labs/windmill/issues/9259)) ([78cf6c7](https://github.com/windmill-labs/windmill/commit/78cf6c7f8181ad431cffebd18c45a3a802b3a601))
* scope VSCode webview clipboard paste to focused editor ([#9221](https://github.com/windmill-labs/windmill/issues/9221)) ([bd06282](https://github.com/windmill-labs/windmill/commit/bd062825a255da364c1590820ead65172e835d13))
## [1.704.1](https://github.com/windmill-labs/windmill/compare/v1.704.0...v1.704.1) (2026-05-19)
### Bug Fixes
* fix git sync ([ff1deaa](https://github.com/windmill-labs/windmill/commit/ff1deaa7e2f3f1f650861c1e2a0f663e597d501c))
* honor SAML RelayState to redirect to deep link after SSO login ([#9225](https://github.com/windmill-labs/windmill/issues/9225)) ([89306d7](https://github.com/windmill-labs/windmill/commit/89306d7dbc96d0c7dfe2c6025cefc2d72e4f224e))
* revert git sync script bump ([0f54ecd](https://github.com/windmill-labs/windmill/commit/0f54ecd34cf1ac86ded9305bc84a044eb6a86e72))
## [1.704.0](https://github.com/windmill-labs/windmill/compare/v1.703.3...v1.704.0) (2026-05-18)
### Features
* add global ask user question tool ([#9217](https://github.com/windmill-labs/windmill/issues/9217)) ([f965512](https://github.com/windmill-labs/windmill/commit/f965512c7a9aca32c252ca0cda7ec00ab08a38e0))
* add global chat selected context ([#9216](https://github.com/windmill-labs/windmill/issues/9216)) ([49ebf6f](https://github.com/windmill-labs/windmill/commit/49ebf6f8ba0ea55ea7987f40ecdd32738241a3f2))
* show job status in favicon on the run page ([#9206](https://github.com/windmill-labs/windmill/issues/9206)) ([2e05bdd](https://github.com/windmill-labs/windmill/commit/2e05bdd73a664ddeec513653ab74e8c696ea1cfd))
### Bug Fixes
* don't fail flow on AlreadyCompleted after zombie restart ([#9214](https://github.com/windmill-labs/windmill/issues/9214)) ([8b7f7b3](https://github.com/windmill-labs/windmill/commit/8b7f7b37bdb91449cbd868bd3ee33a0ccbaf288f))
* **git-sync:** bump default sync script to hub/28229 for extra_perms support ([#9223](https://github.com/windmill-labs/windmill/issues/9223)) ([0538412](https://github.com/windmill-labs/windmill/commit/0538412f1c370981be1915d8c724879d2c54fb83))
* preserve ai reasoning content ([#9208](https://github.com/windmill-labs/windmill/issues/9208)) ([fec4008](https://github.com/windmill-labs/windmill/commit/fec40086961174fea25b4e1f796991152b84b211))
* reject path traversal in MCP endpoint path parameters ([#9211](https://github.com/windmill-labs/windmill/issues/9211)) ([ad5ec29](https://github.com/windmill-labs/windmill/commit/ad5ec293b5a189135faea21e0d9c93637b77670f))
* resolve absolute-path imports in monaco ts editor ([#9213](https://github.com/windmill-labs/windmill/issues/9213)) ([156eb0b](https://github.com/windmill-labs/windmill/commit/156eb0b045171e8d6990af9eeab752071bf7097b))
## [1.703.3](https://github.com/windmill-labs/windmill/compare/v1.703.2...v1.703.3) (2026-05-18)
### Bug Fixes
* constrain unauthenticated get_public_resource to app_theme resources ([#9203](https://github.com/windmill-labs/windmill/issues/9203)) ([24eedef](https://github.com/windmill-labs/windmill/commit/24eedef918376d9d401335b6fada577916f8cc0e))
* enforce folder ACL on flow run-by-version routes ([#9202](https://github.com/windmill-labs/windmill/issues/9202)) ([ab11c77](https://github.com/windmill-labs/windmill/commit/ab11c7747a9076e8121fcea6eafb8e88079ac987))
* enforce jobs:run scope on job preview and inline endpoints ([#9198](https://github.com/windmill-labs/windmill/issues/9198)) ([664edcd](https://github.com/windmill-labs/windmill/commit/664edcdfb746f6c8513e2b487383b5d9ab9f5434))
* **mcp:** validate oauth dynamic client registration redirect_uris ([#9197](https://github.com/windmill-labs/windmill/issues/9197)) ([8bc2295](https://github.com/windmill-labs/windmill/commit/8bc2295b94df159a7c8630cdbe02953b8b7c13a1))
* validate entrypoint override to prevent worker code injection (GHSA-wxjq-w5pj-jqhx) ([#9204](https://github.com/windmill-labs/windmill/issues/9204)) ([bd05bca](https://github.com/windmill-labs/windmill/commit/bd05bcadde06b65fc4b732f576d89aae908b5a3f))
## [1.703.2](https://github.com/windmill-labs/windmill/compare/v1.703.1...v1.703.2) (2026-05-17)
### Bug Fixes
* prevent cross-tenant DNS poisoning via writable /etc in nsjail ([#9194](https://github.com/windmill-labs/windmill/issues/9194)) ([f8467f3](https://github.com/windmill-labs/windmill/commit/f8467f38c8a053117ce62f96684cfb15ef792f08))
## [1.703.1](https://github.com/windmill-labs/windmill/compare/v1.703.0...v1.703.1) (2026-05-16)
### Bug Fixes
* actionable error when a custom_path is taken by an app in another workspace ([#9190](https://github.com/windmill-labs/windmill/issues/9190)) ([dfeed9c](https://github.com/windmill-labs/windmill/commit/dfeed9c5c2e39bf3e10eea4f69ea140ee9e7832f))
* atomic bundle cache writes to prevent parallel cold-load race ([#9186](https://github.com/windmill-labs/windmill/issues/9186)) ([81b5736](https://github.com/windmill-labs/windmill/commit/81b573610692b386e4861ef989fa7698b53fc861))
* detect S3 assets passed as SDK object arg in ts parser ([#9181](https://github.com/windmill-labs/windmill/issues/9181)) ([6a334e9](https://github.com/windmill-labs/windmill/commit/6a334e9a07a7d0cffabde48be75263b0844d586c))
* don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs ([#9188](https://github.com/windmill-labs/windmill/issues/9188)) ([4e25954](https://github.com/windmill-labs/windmill/commit/4e259547225e13e5b51a166a84cdbbbfa35c3264))
* reset parent_hash in auto_parent when all versions at path are archived ([#9172](https://github.com/windmill-labs/windmill/issues/9172)) ([52960ca](https://github.com/windmill-labs/windmill/commit/52960ca30ab9c019186a28b3ab054a1dfe72f451))
## [1.703.0](https://github.com/windmill-labs/windmill/compare/v1.702.1...v1.703.0) (2026-05-15)
### Features
* **otel-tracing-proxy:** configurable tracing MITM NO_PROXY hosts ([#9169](https://github.com/windmill-labs/windmill/issues/9169)) ([d48d61c](https://github.com/windmill-labs/windmill/commit/d48d61cc79114f0b36736306d4015789be10c1f4))
### Bug Fixes
* aggregate wait time should target the true root job, not flow_innermost_root_job ([#9177](https://github.com/windmill-labs/windmill/issues/9177)) ([e181931](https://github.com/windmill-labs/windmill/commit/e1819313e15766007c959497a84fae5f5c78a46b))
* apply pip_local_dependencies filtering to deployed scripts with populated lockfiles ([#9178](https://github.com/windmill-labs/windmill/issues/9178)) ([69b3141](https://github.com/windmill-labs/windmill/commit/69b3141e0370b95f2e13987503480d341608dbdf))
* never mark failure/trigger/approval scripts as auto_kind=lib ([#9168](https://github.com/windmill-labs/windmill/issues/9168)) ([f414ffc](https://github.com/windmill-labs/windmill/commit/f414ffc4849cf4b92fcd5ca9611ecd246e59a7bd))
## [1.702.1](https://github.com/windmill-labs/windmill/compare/v1.702.0...v1.702.1) (2026-05-14)
### Bug Fixes
* **nativets:** pass tracing-enabled OtelConfig to deno_telemetry::init ([#9163](https://github.com/windmill-labs/windmill/issues/9163)) ([bf99283](https://github.com/windmill-labs/windmill/commit/bf99283c3333bcdbc7679f4aea04ba29e41a48a5))
## [1.702.0](https://github.com/windmill-labs/windmill/compare/v1.701.0...v1.702.0) (2026-05-14)
### Features
* **git-sync:** sync extra_perms for flows/scripts/apps ([#9162](https://github.com/windmill-labs/windmill/issues/9162)) ([5e909b2](https://github.com/windmill-labs/windmill/commit/5e909b2b4f2819f19deaf06d9e78e6458b324683))
* include service accounts in instance settings users list ([#9157](https://github.com/windmill-labs/windmill/issues/9157)) ([e5286f4](https://github.com/windmill-labs/windmill/commit/e5286f46074cf2893e6ccd26175f929f16011c8f))
### Bug Fixes
* **mcp:** sanitize and enrich nested resource schemas ([#9158](https://github.com/windmill-labs/windmill/issues/9158)) ([d870edc](https://github.com/windmill-labs/windmill/commit/d870edc959481a06c894b4eda5e2be1a0269d7d0))
## [1.701.0](https://github.com/windmill-labs/windmill/compare/v1.700.2...v1.701.0) (2026-05-13)
### Features
* **frontend:** unified EditorHeader with file picker for flow/script/app editors ([#9047](https://github.com/windmill-labs/windmill/issues/9047)) ([d0f23cc](https://github.com/windmill-labs/windmill/commit/d0f23cc5238b025208c61e983701894de28536d5))
* read-only flag on API tokens ([#9144](https://github.com/windmill-labs/windmill/issues/9144)) ([d666e84](https://github.com/windmill-labs/windmill/commit/d666e8431cdbf14d9373d9ef625b5aafc50ac50a))
### Bug Fixes
* align script path existence check with deploy logic; hide Delete for non-admin ([#9152](https://github.com/windmill-labs/windmill/issues/9152)) ([c509206](https://github.com/windmill-labs/windmill/commit/c5092069cbeda2c4c18bea80dd629c7c087b30bf))
* Allow devops role to use all_workspaces runs filter in admins workspace ([#9153](https://github.com/windmill-labs/windmill/issues/9153)) ([110bef0](https://github.com/windmill-labs/windmill/commit/110bef0a6e76615c7b371c5c0f5bc1f4e7a73a64))
* **bun:** pass --preserve-symlinks on unbundled execution ([#9147](https://github.com/windmill-labs/windmill/issues/9147)) ([4d0f2c2](https://github.com/windmill-labs/windmill/commit/4d0f2c26a116a0f8a89a64231dc824eabda0a8c3))
* **cli:** prevent !inline-corruption in flow push/pull ([#9142](https://github.com/windmill-labs/windmill/issues/9142)) ([79c5b7b](https://github.com/windmill-labs/windmill/commit/79c5b7b8b7676b0a06fa6480dd04b7105d39d250))
* **operator:** refresh IAM RDS / Entra ID tokens in operator process ([#9141](https://github.com/windmill-labs/windmill/issues/9141)) ([7ebb081](https://github.com/windmill-labs/windmill/commit/7ebb08133cd4027bc00bacc4a0fc5865cd5709ec))
* **python:** preserve strings containing Infinity/NaN in result JSON ([#9149](https://github.com/windmill-labs/windmill/issues/9149)) ([33bf01b](https://github.com/windmill-labs/windmill/commit/33bf01b627c8ea430c03dfc27a97a8f2d770582f))
* scope promotion-mode debounce key per repo ([#9145](https://github.com/windmill-labs/windmill/issues/9145)) ([2ec1863](https://github.com/windmill-labs/windmill/commit/2ec1863340e759bba3408dbc4f41b16912b959ea))
* send flow push-loop ping outside transaction so zombie monitor sees it ([#9136](https://github.com/windmill-labs/windmill/issues/9136)) ([818cb31](https://github.com/windmill-labs/windmill/commit/818cb31fbc731fa5c70ddf5942bb37bc4bc56e4d))
### Performance Improvements
* **dynselect:** only retrigger when helper args actually change ([#9148](https://github.com/windmill-labs/windmill/issues/9148)) ([dd19e52](https://github.com/windmill-labs/windmill/commit/dd19e52a84fb9a9f48e3ad061b084841c2ee7464))
## [1.700.2](https://github.com/windmill-labs/windmill/compare/v1.700.1...v1.700.2) (2026-05-12)
+19 -5
View File
@@ -66,6 +66,7 @@ RUN npm ci
COPY frontend .
RUN mkdir /backend
COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml
COPY /backend/oauth_connect.json /backend/oauth_connect.json
COPY /openflow.openapi.yaml /openflow.openapi.yaml
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
COPY /system_prompts/auto-generated /system_prompts/auto-generated
@@ -232,11 +233,14 @@ ENV PATH="${PATH}:/usr/local/go/bin"
ENV GO_PATH=/usr/local/go/bin/go
# Install UV
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
# Preinstall python runtimes to temp build location (will copy with world-writable perms later)
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY
# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run
# under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve
# timestamps or Python's mtime-based .pyc invalidation discards these compiled files.
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11 --compile-bytecode
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -
@@ -258,7 +262,7 @@ RUN export GOCACHE=/tmp/build_cache/go && \
# chmod a+rw adds read+write WITHOUT removing execute bits (755->777, 644->666)
# Note: uv python install only creates py_runtime, not uv cache - we create uv/go dirs for runtime
RUN mkdir -p /tmp/windmill/cache && \
cp -r /tmp/build_cache/* /tmp/windmill/cache/ && \
cp -r --preserve=timestamps /tmp/build_cache/* /tmp/windmill/cache/ && \
chmod -R a+rw /tmp/windmill/cache && \
rm -rf /tmp/build_cache && \
mkdir -p -m 777 /tmp/windmill/cache/uv /tmp/windmill/cache/go /tmp/windmill/cache/rustup /tmp/windmill/cache/cargo
@@ -299,10 +303,20 @@ ENV CARGO_HOME="/tmp/windmill/cache/cargo"
ENV LD_LIBRARY_PATH="."
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
# crane: pulls + flattens images for the sandboxed container runtime (`# sandbox <image>`).
# Single static binary — no daemon/store/root needed. See docs/docker-v2-runtime.md.
ARG CRANE_VERSION=v0.20.6
RUN arch="$(dpkg --print-architecture)"; \
case "$arch" in amd64) crane_arch=x86_64 ;; arm64) crane_arch=arm64 ;; *) echo >&2 "error: unsupported arch '$arch' for crane"; exit 1 ;; esac; \
wget -O /tmp/crane.tgz "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_${crane_arch}.tar.gz" \
&& tar -xzf /tmp/crane.tgz -C /usr/local/bin crane \
&& rm /tmp/crane.tgz \
&& chmod +x /usr/local/bin/crane
WORKDIR ${APP}
RUN ln -s ${APP}/windmill /usr/local/bin/windmill
+25
View File
@@ -86,6 +86,31 @@ Global prompts should exercise workspace-level drafting behavior:
Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them.
Datatable cases should set `skipJudge: true` and validate through tool-use
(`requiredToolsUsed` / `forbiddenToolsUsed`) and SQL-argument assertions
(`toolCallArgs` with `stringIncludesAnyOf`, e.g. `['select']`, `['create table']`,
`['update', 'insert into']`). Two reasons the judge is unreliable here:
- `list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql`
produce no drafts, and the global judge only sees the drafts artifact — it
scores a no-draft conversational answer as empty (same as the
`askUserQuestion` cases).
- Even a case that *does* produce a draft (a script reading the data table via
`wmill.datatable()` at runtime) is mis-judged: the judge has no datatable SDK
reference and penalizes correct `wmill.datatable()` usage as wrong. Verify the
SDK call deterministically instead — `requiredDrafts.valueIncludes: ['wmill.datatable(']`
plus forbidding `exec_datatable_sql` (keeping chat-time SQL distinct from
runtime SDK use).
`stringIncludesAnyOf` is existential over calls (at least one matching call), so a
mutation case still passes when the model mixes its UPDATE/INSERT with
verification SELECTs. The in-memory engine (`datatableSqlEngine.ts`) is stateful
within a case — writes persist, so a model that re-queries to verify its
CREATE/UPDATE sees the change and does not loop. But the engine is best-effort
(SELECT returns all rows of the referenced/first table with no WHERE/projection),
so still never assert specific returned row values. Seed data via
`workspace.datatables` in the `initial` fixture (see README).
## Deterministic validation
Use deterministic validation only for hard failures such as:
+39 -8
View File
@@ -56,7 +56,7 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus
bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o
bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose
bun run cli -- run flow --record
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-3-flash-preview
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview
bun run cli -- run global global-test1-script-create
bun run cli -- run cli bun-hello-script
@@ -88,15 +88,16 @@ Today:
- `sonnet`
- `opus`
- `4o`
- `gemini-flash`
- `gemini-pro`
- `gpt-5.5`
- `gemini-3-flash-preview`
- `gemini-3.1-pro-preview`
- `deepseek-v4-flash`
- `deepseek-v4-pro`
Notes:
- the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5`
- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases
- the command also prints accepted alias spellings such as `gpt-4o`, `gpt-55`, `claude-opus-4.6`, and `claude-haiku-4.5`
- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, Gemini, and DeepSeek-backed aliases
- `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there
- the judge model is separate and currently defaults to `claude-sonnet-4-6`
@@ -142,6 +143,32 @@ For `global` mode, `validate` can express draft-level requirements such as:
- required or forbidden draft counts
- forbidden draft paths
Global initial fixtures can also seed `liveEditorDrafts` with `type`,
`storagePath`, `effectivePath`, and `value` fields. These drafts emulate the
currently open script, flow, or raw app editor so cases can test prompts that
refer to "this" or the "current" item.
Global (and flow) initial fixtures can seed `workspace.datatables` so the
`list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql` tools
return seeded data during evals. Each entry is
`{ datatable_name, schemas: { <schema>: { <table>: { columns, rows? } } } }`.
SQL runs through a small in-memory engine (`datatableSqlEngine.ts`), not a real
database. Writes are **stateful within a case**: `CREATE`/`DROP`/`INSERT`/`UPDATE`/
`DELETE` mutate the seeded datatable in place, so a later `list_datatables`,
`get_datatable_table_schema`, `SELECT`, or `information_schema` query reflects them
— this is what stops a model from looping when it re-queries to verify a write.
The engine is best-effort: `SELECT` returns all rows of the referenced (or first)
table with no WHERE filtering/projection/joins, `WHERE` on UPDATE/DELETE supports
`col = value` predicates joined by `AND`, and anything unparseable is a no-op
success. So validate datatable cases through tool-use and SQL-argument assertions
(`requiredToolsUsed`, `stringIncludesAnyOf`) — not through exact returned row
values. An empty/absent `datatables` seed makes `list_datatables` return `[]`,
which is what the "no datatable configured" blocking cases rely on.
Set `WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT=1` to run those cases with
the old behavior where the live editor is only discoverable through
`list_workspace_items`.
App fixtures can also include an optional `datatables.json` file at the fixture root.
For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of
@@ -189,11 +216,15 @@ If `--record` is used, the CLI also appends one compact JSON line to:
Each recorded line contains:
- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`)
- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`)
- average token usage (`averageTokenUsagePerAttempt`)
- per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate)
- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averagePassedDurationMs`, `averageJudgeScore`)
- average token usage (`averageTokenUsagePerAttempt`, `averageTokenUsagePerPassedAttempt`)
- per-case metrics under `cases[]` (`averageDurationMs`, `averagePassedDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, `averageTokenUsagePerPassedAttempt`, pass rate)
- `failedCaseIds`
The CLI headline duration and token averages use passed attempts only.
All-attempt averages are still recorded to make failures auditable without
letting failed attempts skew success cost comparisons.
Example:
- summary: `ai_evals/results/2026-04-09T09-40-33.051Z__flow.json`
+15 -11
View File
@@ -10,10 +10,6 @@ import { runSuite } from "../../core/runSuite";
import type { BenchmarkRunResult, ModeRunner } from "../../core/types";
import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettings";
import { emitFrontendBenchmarkProgress } from "./progress";
import { createAppModeRunner } from "../../modes/app";
import { createFlowModeRunner } from "../../modes/flow";
import { createGlobalModeRunner } from "../../modes/global";
import { createScriptModeRunner } from "../../modes/script";
import { DEFAULT_JUDGE_MODEL } from "../../core/judge";
export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global";
@@ -40,7 +36,7 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
const backendSettings = resolveWindmillBackendSettings();
const selectedCases = await loadSelectedCases(mode, caseIds);
const modeRunner = getModeRunner(
const modeRunner = await getModeRunner(
mode,
getFrontendEvalModel(model),
backendValidation,
@@ -69,25 +65,33 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
});
}
function getModeRunner(
async function getModeRunner(
mode: FrontendBenchmarkMode,
model: ReturnType<typeof getFrontendEvalModel>,
backendValidation: ReturnType<typeof resolveBackendValidationSettings>,
backendSettings: ReturnType<typeof resolveWindmillBackendSettings>,
): ModeRunner<any, any, any> {
): Promise<ModeRunner<any, any, any>> {
switch (mode) {
case "flow":
case "flow": {
const { createFlowModeRunner } = await import("../../modes/flow");
return createFlowModeRunner(model, backendValidation, backendSettings);
case "app":
}
case "app": {
const { createAppModeRunner } = await import("../../modes/app");
return createAppModeRunner(model, backendSettings);
case "script":
}
case "script": {
const { createScriptModeRunner } = await import("../../modes/script");
return createScriptModeRunner(
model,
backendValidation,
backendSettings,
);
case "global":
}
case "global": {
const { createGlobalModeRunner } = await import("../../modes/global");
return createGlobalModeRunner(model, backendSettings);
}
}
}
@@ -7,8 +7,12 @@ import {
prepareGlobalSystemMessage,
prepareGlobalUserMessage,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte";
import {
clearGlobalDrafts,
listGlobalDrafts,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte";
import type { ModeRunContext } from "../../../../core/types";
import type { GlobalDraftState } from "../../../../core/validators";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
@@ -24,6 +28,21 @@ const MUTATING_GLOBAL_TOOLS = new Set([
"deploy_workspace_item",
"delete_workspace_item",
]);
const DISABLE_ACTIVE_EDITOR_CONTEXT_ENV =
"WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT";
const LIVE_EDITOR_ITEM_KINDS = {
script: "script",
flow: "flow",
app: "raw_app",
} as const;
export interface GlobalLiveEditorDraftFixture {
type: keyof typeof LIVE_EDITOR_ITEM_KINDS;
storagePath?: string;
effectivePath?: string;
value?: unknown;
}
export interface GlobalEvalResult {
success: boolean;
@@ -38,6 +57,7 @@ export interface GlobalEvalResult {
export interface GlobalEvalOptions {
workspaceFixtures?: BenchmarkWorkspaceRunnables;
liveEditorDrafts?: GlobalLiveEditorDraftFixture[];
model?: string;
maxIterations?: number;
provider?: AIProvider;
@@ -55,19 +75,26 @@ export async function runGlobalEval(
options.workspaceRoot ??
(await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-")));
globalDraftStore.clearDrafts(workspaceRoot);
clearGlobalDrafts(workspaceRoot);
registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {});
seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
try {
const model = options.model ?? "claude-haiku-4-5-20251001";
const injectActiveEditorContext =
process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1";
const rawResult = await runEval({
userPrompt,
systemMessage: prepareGlobalSystemMessage(),
userMessage: prepareGlobalUserMessage(userPrompt),
userMessage: prepareGlobalUserMessage(
userPrompt,
[],
injectActiveEditorContext ? { workspace: workspaceRoot } : {},
),
tools: getGlobalEvalTools(),
helpers: {},
apiKey,
getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }),
getOutput: () => ({ drafts: listGlobalDrafts(workspaceRoot) }),
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
onAssistantToken: options.runContext?.onAssistantChunk,
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
@@ -94,7 +121,8 @@ export async function runGlobalEval(
tokenUsage: rawResult.tokenUsage,
};
} finally {
globalDraftStore.clearDrafts(workspaceRoot);
clearGlobalDrafts(workspaceRoot);
clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
if (!options.workspaceRoot) {
await rm(workspaceRoot, { recursive: true, force: true });
@@ -102,6 +130,36 @@ export async function runGlobalEval(
}
}
function seedLiveEditorDrafts(
workspace: string,
fixtures: GlobalLiveEditorDraftFixture[],
): void {
for (const fixture of fixtures) {
const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type];
const storagePath = fixture.storagePath ?? fixture.effectivePath ?? "";
if (fixture.value !== undefined) {
UserDraft.save(itemKind, storagePath, fixture.value, { workspace });
}
UserDraft.setLiveEditorDraft({
workspace,
itemKind,
storagePath,
effectivePath: fixture.effectivePath ?? fixture.storagePath,
});
}
}
function clearLiveEditorDrafts(
workspace: string,
fixtures: GlobalLiveEditorDraftFixture[],
): void {
for (const fixture of fixtures) {
const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type];
const storagePath = fixture.storagePath ?? fixture.effectivePath ?? "";
UserDraft.clearLiveEditorDraft(itemKind, { workspace, storagePath });
}
}
function getGlobalEvalTools(): ProductionTool<{}>[] {
return (globalTools as ProductionTool<{}>[]).map((tool) => {
if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) {
@@ -236,7 +236,8 @@ function toFrontendEvalProvider(
if (
provider === "anthropic" ||
provider === "openai" ||
provider === "googleai"
provider === "googleai" ||
provider === "deepseek"
) {
return provider;
}
@@ -21,16 +21,25 @@ describe("proxy helpers", () => {
describe("resolveEvalModelProvider", () => {
it("infers googleai from Gemini model ids", () => {
expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({
expect(resolveEvalModelProvider("gemini-3-flash-preview")).toEqual({
provider: "googleai",
model: "gemini-2.5-flash",
model: "gemini-3-flash-preview",
});
});
it("infers deepseek from DeepSeek model ids", () => {
expect(resolveEvalModelProvider("deepseek-v4-flash")).toEqual({
provider: "deepseek",
model: "deepseek-v4-flash",
});
});
it("preserves an explicit provider", () => {
expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({
expect(
resolveEvalModelProvider("gemini-3.1-pro-preview", "googleai"),
).toEqual({
provider: "googleai",
model: "gemini-2.5-pro",
model: "gemini-3.1-pro-preview",
});
});
});
@@ -83,6 +83,9 @@ export function resolveEvalModelProvider(
if (model.startsWith("gemini")) {
return { provider: "googleai", model };
}
if (model.startsWith("deepseek")) {
return { provider: "deepseek", model };
}
if (model.startsWith("gpt") || model.startsWith("o")) {
return { provider: "openai", model };
}
@@ -0,0 +1,262 @@
import { describe, expect, it } from 'bun:test'
import { applyDatatableSql, type BenchmarkDatatableSeed } from './datatableSqlEngine'
function makeDatatable(): BenchmarkDatatableSeed {
return {
datatable_name: 'main',
schemas: {
public: {
orders: {
columns: { id: 'int4', customer_id: 'int4', total: 'numeric', status: 'text' },
rows: [
{ id: 1, customer_id: 1, total: 42.5, status: 'shipped' },
{ id: 2, customer_id: 2, total: 19.99, status: 'pending' },
{ id: 3, customer_id: 1, total: 88, status: 'shipped' }
]
},
customers: {
columns: { id: 'int4', name: 'text' },
rows: [{ id: 1, name: 'Alice' }]
}
}
}
}
}
describe('SELECT', () => {
it('returns the referenced table rows', () => {
const dt = makeDatatable()
expect(applyDatatableSql(dt, 'SELECT id, name FROM customers').rows).toEqual([
{ id: 1, name: 'Alice' }
])
})
it('falls back to the first table when no known table is referenced', () => {
const dt = makeDatatable()
expect(applyDatatableSql(dt, 'select 1').rows).toHaveLength(3)
})
it('resolves a schema-qualified table', () => {
const dt = makeDatatable()
expect(applyDatatableSql(dt, 'SELECT * FROM public.customers').rows).toEqual([
{ id: 1, name: 'Alice' }
])
})
})
describe('CREATE TABLE', () => {
it('adds a table with parsed columns, skipping table constraints and FK clauses', () => {
const dt = makeDatatable()
const result = applyDatatableSql(
dt,
'CREATE TABLE public.refunds (\n order_id int4 NOT NULL REFERENCES public.orders(id),\n amount numeric(10,2),\n PRIMARY KEY (order_id)\n)'
)
expect(result.rows).toEqual([])
expect(dt.schemas.public.refunds).toEqual({
columns: { order_id: 'int4', amount: 'numeric(10,2)' },
rows: []
})
})
it('defaults an unqualified table to the public schema', () => {
const dt = makeDatatable()
applyDatatableSql(dt, 'CREATE TABLE notes (id int4, body text)')
expect(dt.schemas.public.notes.columns).toEqual({ id: 'int4', body: 'text' })
})
it('is a no-op for an existing table with IF NOT EXISTS', () => {
const dt = makeDatatable()
applyDatatableSql(dt, 'CREATE TABLE IF NOT EXISTS public.orders (x int4)')
expect(Object.keys(dt.schemas.public.orders.columns)).toContain('status')
})
})
describe('DROP TABLE', () => {
it('removes the table', () => {
const dt = makeDatatable()
applyDatatableSql(dt, 'DROP TABLE IF EXISTS public.customers')
expect(dt.schemas.public.customers).toBeUndefined()
})
})
describe('INSERT', () => {
it('appends a row using an explicit column list', () => {
const dt = makeDatatable()
applyDatatableSql(dt, "INSERT INTO customers (id, name) VALUES (2, 'Bob')")
expect(dt.schemas.public.customers.rows).toContainEqual({ id: 2, name: 'Bob' })
})
it('infers columns from the table when none are given, and appends multiple tuples', () => {
const dt = makeDatatable()
applyDatatableSql(dt, "INSERT INTO customers VALUES (2, 'Bob'), (3, 'Carol')")
expect(dt.schemas.public.customers.rows).toHaveLength(3)
})
it('returns the inserted rows when RETURNING is present', () => {
const dt = makeDatatable()
const result = applyDatatableSql(
dt,
"INSERT INTO customers (id, name) VALUES (2, 'Bob') RETURNING *"
)
expect(result.rows).toEqual([{ id: 2, name: 'Bob' }])
})
})
describe('UPDATE', () => {
it('updates only the rows matching an equality WHERE', () => {
const dt = makeDatatable()
const result = applyDatatableSql(
dt,
"UPDATE public.orders SET status = 'shipped' WHERE id = 2"
)
expect(result.rows).toEqual([])
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('shipped')
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 1)?.status).toBe('shipped')
})
it('strips a Postgres cast in the WHERE value', () => {
const dt = makeDatatable()
applyDatatableSql(dt, "UPDATE orders SET status = 'done' WHERE id = 2::int4")
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('done')
})
it('matches multiple AND predicates including a numeric literal', () => {
const dt = makeDatatable()
applyDatatableSql(
dt,
"UPDATE orders SET status = 'done' WHERE customer_id = 2 AND total = 19.99"
)
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('done')
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 1)?.status).toBe('shipped')
})
it('updates every row when there is no WHERE', () => {
const dt = makeDatatable()
applyDatatableSql(dt, "UPDATE orders SET status = 'archived'")
expect(dt.schemas.public.orders.rows?.every((r) => r.status === 'archived')).toBe(true)
})
it('returns the affected rows when RETURNING is present', () => {
const dt = makeDatatable()
const result = applyDatatableSql(
dt,
"UPDATE orders SET status = 'shipped' WHERE id = 2 RETURNING *"
)
expect(result.rows).toHaveLength(1)
expect(result.rows[0]).toMatchObject({ id: 2, status: 'shipped' })
})
it('affects no rows when the WHERE clause cannot be parsed', () => {
const dt = makeDatatable()
applyDatatableSql(dt, "UPDATE orders SET status = 'x' WHERE total > 20")
expect(dt.schemas.public.orders.rows?.some((r) => r.status === 'x')).toBe(false)
})
})
describe('DELETE', () => {
it('removes only the matching rows', () => {
const dt = makeDatatable()
applyDatatableSql(dt, 'DELETE FROM orders WHERE id = 2')
expect(dt.schemas.public.orders.rows?.map((r) => r.id)).toEqual([1, 3])
})
it('returns the removed rows when RETURNING is present', () => {
const dt = makeDatatable()
const result = applyDatatableSql(dt, 'DELETE FROM orders WHERE id = 2 RETURNING *')
expect(result.rows).toEqual([{ id: 2, customer_id: 2, total: 19.99, status: 'pending' }])
})
})
describe('writes are reflected by later reads', () => {
it('UPDATE then SELECT sees the new value (the verify-loop fix)', () => {
const dt = makeDatatable()
applyDatatableSql(dt, "UPDATE orders SET status = 'shipped' WHERE id = 2")
const seen = applyDatatableSql(dt, 'SELECT * FROM orders').rows
expect(seen.find((r) => r.id === 2)?.status).toBe('shipped')
})
it('INSERT then SELECT sees the new row', () => {
const dt = makeDatatable()
applyDatatableSql(dt, "INSERT INTO customers (id, name) VALUES (9, 'Zed')")
const seen = applyDatatableSql(dt, 'SELECT * FROM customers').rows
expect(seen).toContainEqual({ id: 9, name: 'Zed' })
})
it('CREATE then SELECT on the new table returns its (empty) rows', () => {
const dt = makeDatatable()
applyDatatableSql(dt, 'CREATE TABLE public.refunds (order_id int4, amount numeric)')
expect(applyDatatableSql(dt, 'SELECT * FROM refunds').rows).toEqual([])
})
})
describe('system-catalog queries reflect the current tables/columns', () => {
it('lists current tables (including a freshly created one) via information_schema.tables', () => {
const dt = makeDatatable()
applyDatatableSql(dt, 'CREATE TABLE public.refunds (order_id int4)')
const rows = applyDatatableSql(
dt,
"SELECT table_name FROM information_schema.tables WHERE table_name = 'refunds'"
).rows
expect(rows.map((r) => r.table_name)).toContain('refunds')
})
it('does not list a dropped table', () => {
const dt = makeDatatable()
applyDatatableSql(dt, 'DROP TABLE public.customers')
const rows = applyDatatableSql(dt, 'SELECT table_name FROM information_schema.tables').rows
expect(rows.map((r) => r.table_name)).not.toContain('customers')
})
it('reports columns via information_schema.columns', () => {
const dt = makeDatatable()
const rows = applyDatatableSql(
dt,
"SELECT column_name FROM information_schema.columns WHERE table_name = 'orders'"
).rows
expect(rows.map((r) => r.column_name)).toContain('status')
})
})
describe('parser robustness (string/paren-aware splitting)', () => {
it('does not treat the word "returning" inside a string value as a RETURNING clause', () => {
const dt = makeDatatable()
const result = applyDatatableSql(
dt,
"INSERT INTO customers (id, name) VALUES (5, 'is returning soon')"
)
expect(result.rows).toEqual([])
expect(dt.schemas.public.customers.rows).toContainEqual({ id: 5, name: 'is returning soon' })
})
it('does not split on the word "where" inside a SET string value', () => {
const dt = makeDatatable()
applyDatatableSql(dt, "UPDATE orders SET status = 'ship where ordered' WHERE id = 2")
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('ship where ordered')
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 1)?.status).toBe('shipped')
})
it('keeps INSERT tuples intact when a value contains a function call', () => {
const dt = makeDatatable()
applyDatatableSql(dt, "INSERT INTO customers (id, name) VALUES (6, coalesce(NULL, 'x'))")
expect(dt.schemas.public.customers.rows).toHaveLength(2)
expect(dt.schemas.public.customers.rows?.[1]).toMatchObject({ id: 6 })
})
it('CREATE TABLE ignores a trailing semicolon-separated statement', () => {
const dt = makeDatatable()
applyDatatableSql(
dt,
'CREATE TABLE public.refunds (id int4, amount numeric); INSERT INTO refunds VALUES (1, 5)'
)
expect(dt.schemas.public.refunds.columns).toEqual({ id: 'int4', amount: 'numeric' })
expect(dt.schemas.public.refunds.rows).toEqual([])
})
})
describe('unparseable statements are a safe no-op', () => {
it('returns [] and does not throw', () => {
const dt = makeDatatable()
expect(applyDatatableSql(dt, 'VACUUM ANALYZE').rows).toEqual([])
expect(applyDatatableSql(dt, 'GRANT SELECT ON orders TO someone').rows).toEqual([])
})
})
@@ -0,0 +1,541 @@
/**
* A deliberately small, best-effort SQL engine for the benchmark datatable mock.
*
* This is NOT a real SQL implementation — it exists only so that writes a model
* issues during an eval (`CREATE TABLE`, `INSERT`, `UPDATE`, `DELETE`, `DROP`)
* become visible to its later reads (`list_datatables`, `get_datatable_table_schema`,
* `SELECT`). Without that, a model that re-queries to verify a write sees stale
* seed data, concludes the write failed, and loops until it exhausts its turns.
*
* It parses only the common statement shapes models produce. Anything it cannot
* parse is a no-op success (it never throws) — behavioral evals assert that the
* right statement was issued, not its exact data effects. Notable limits:
* - `SELECT` returns all rows of the referenced (or first) table — no WHERE
* filtering, projection, joins, or aggregation.
* - `WHERE` supports `col = value` predicates joined by `AND` only; an
* unparseable WHERE on UPDATE/DELETE affects zero rows (never the whole table).
*/
/** One seeded datatable table: its columns (col -> compact_type) and optional rows. */
export interface BenchmarkDatatableTableSeed {
columns: Record<string, string>
rows?: Record<string, unknown>[]
}
/** A seeded datatable: `datatable_name` plus a `schema -> table -> seed` map. */
export interface BenchmarkDatatableSeed {
datatable_name: string
schemas: {
[schema: string]: {
[table: string]: BenchmarkDatatableTableSeed
}
}
}
export interface DatatableSqlResult {
rows: Record<string, unknown>[]
}
const DEFAULT_SCHEMA = 'public'
type ParsedRef = { schema: string; table: string }
type Predicate = { column: string; value: unknown }
/**
* Apply one SQL statement to `datatable` IN PLACE and return the result rows.
* SELECT returns the referenced/first table's rows; a mutation returns its
* affected rows when it has a RETURNING clause, otherwise `[]`.
*/
export function applyDatatableSql(
datatable: BenchmarkDatatableSeed,
sql: string
): DatatableSqlResult {
const statement = stripTrailingSemicolon(sql.trim())
if (/^\s*(with|select)\b/i.test(statement)) {
return { rows: selectRows(datatable, statement) }
}
if (/^\s*create\s+table\b/i.test(statement)) {
return { rows: applyCreateTable(datatable, statement) }
}
if (/^\s*drop\s+table\b/i.test(statement)) {
return { rows: applyDropTable(datatable, statement) }
}
if (/^\s*insert\s+into\b/i.test(statement)) {
return { rows: applyInsert(datatable, statement) }
}
if (/^\s*update\b/i.test(statement)) {
return { rows: applyUpdate(datatable, statement) }
}
if (/^\s*delete\s+from\b/i.test(statement)) {
return { rows: applyDelete(datatable, statement) }
}
return { rows: [] }
}
// ============= Reads =============
function selectRows(
datatable: BenchmarkDatatableSeed,
sql: string
): Record<string, unknown>[] {
const fromRef = sql.match(/\bfrom\s+([a-zA-Z_"][\w."]*)/i)?.[1]
if (fromRef) {
const catalog = catalogRows(datatable, fromRef)
if (catalog) {
return catalog
}
}
const table = fromRef ? resolveTable(datatable, fromRef) : undefined
const seed = table ?? firstTable(datatable)
return seed?.rows ?? []
}
/**
* Synthesize rows for a system-catalog query so a model verifying a `CREATE`/`DROP`
* via `information_schema.tables` / `.columns` (or `pg_tables`) sees the current
* tables/columns instead of fallback data. WHERE is not applied, so the model gets
* the full set and finds (or no longer finds) the table it just changed.
* Returns `undefined` for non-catalog refs so normal table resolution proceeds.
*/
function catalogRows(
datatable: BenchmarkDatatableSeed,
ref: string
): Record<string, unknown>[] | undefined {
const normalized = ref.toLowerCase().replace(/"/g, '')
const name = normalized.split('.').pop()
const isCatalog = normalized.includes('information_schema.') || normalized.startsWith('pg_')
if (!isCatalog) {
return undefined
}
const tables = allTables(datatable)
if (name === 'tables' || name === 'pg_tables') {
return tables.map(({ schema, table }) => ({
table_schema: schema,
table_name: table,
schemaname: schema,
tablename: table
}))
}
if (name === 'columns') {
return tables.flatMap(({ schema, table, seed }) =>
Object.entries(seed.columns).map(([column, type]) => ({
table_schema: schema,
table_name: table,
column_name: column,
data_type: type
}))
)
}
return undefined
}
function allTables(
datatable: BenchmarkDatatableSeed
): { schema: string; table: string; seed: BenchmarkDatatableTableSeed }[] {
return Object.entries(datatable.schemas).flatMap(([schema, tables]) =>
Object.entries(tables).map(([table, seed]) => ({ schema, table, seed }))
)
}
// ============= DDL =============
function applyCreateTable(
datatable: BenchmarkDatatableSeed,
sql: string
): Record<string, unknown>[] {
const head = sql.match(
/^\s*create\s+table\s+(?:if\s+not\s+exists\s+)?([a-zA-Z_"][\w."]*)/i
)
// The first top-level paren group is the column-definition list; using it (rather
// than a greedy `(...)` capture) ignores any trailing `;`-separated statement.
const columnText = extractParenGroups(sql)[0]
if (!head || columnText === undefined) {
return []
}
const { schema, table } = parseRef(head[1])
const existing = datatable.schemas[schema]?.[table]
if (existing) {
return []
}
const columns: Record<string, string> = {}
for (const rawDef of splitTopLevel(columnText)) {
const def = rawDef.trim()
if (!def || isTableConstraint(def)) {
continue
}
const tokens = def.split(/\s+/)
const column = unquoteIdentifier(tokens[0])
if (!column) {
continue
}
columns[column] = tokens[1] ?? 'text'
}
if (!datatable.schemas[schema]) {
datatable.schemas[schema] = {}
}
datatable.schemas[schema][table] = { columns, rows: [] }
return []
}
function applyDropTable(
datatable: BenchmarkDatatableSeed,
sql: string
): Record<string, unknown>[] {
const match = sql.match(
/^\s*drop\s+table\s+(?:if\s+exists\s+)?([a-zA-Z_"][\w."]*)/i
)
if (!match) {
return []
}
const { schema, table } = parseRef(match[1])
if (datatable.schemas[schema]?.[table]) {
delete datatable.schemas[schema][table]
}
return []
}
// ============= DML =============
function applyInsert(
datatable: BenchmarkDatatableSeed,
sql: string
): Record<string, unknown>[] {
const { body, returning } = splitOffReturning(sql)
const match = body.match(
/^\s*insert\s+into\s+([a-zA-Z_"][\w."]*)\s*(?:\(([^)]*)\))?\s*values\s*([\s\S]+)$/i
)
if (!match) {
return []
}
const table = resolveTable(datatable, match[1])
if (!table) {
return []
}
const columns = match[2]
? splitTopLevel(match[2]).map((entry) => unquoteIdentifier(entry.trim()))
: Object.keys(table.columns)
const inserted: Record<string, unknown>[] = []
for (const tuple of extractParenGroups(match[3])) {
const values = splitTopLevel(tuple).map((entry) => parseValue(entry))
const row: Record<string, unknown> = {}
columns.forEach((column, index) => {
row[column] = values[index]
})
inserted.push(row)
}
table.rows ??= []
table.rows.push(...inserted)
return returning ? inserted : []
}
function applyUpdate(
datatable: BenchmarkDatatableSeed,
sql: string
): Record<string, unknown>[] {
const { body, returning } = splitOffReturning(sql)
const match = body.match(/^\s*update\s+([a-zA-Z_"][\w."]*)\s+set\s+([\s\S]+)$/i)
if (!match) {
return []
}
const table = resolveTable(datatable, match[1])
if (!table) {
return []
}
let assignmentText = match[2]
let whereText: string | undefined
const whereMatch = maskForClauseScan(assignmentText).match(/\swhere\s/i)
if (whereMatch && whereMatch.index !== undefined) {
whereText = assignmentText.slice(whereMatch.index + whereMatch[0].length)
assignmentText = assignmentText.slice(0, whereMatch.index)
}
const predicates = parsePredicates(whereText)
if (predicates === null) {
return []
}
const assignments: Record<string, unknown> = {}
for (const entry of splitTopLevel(assignmentText)) {
const pair = entry.match(/^\s*([a-zA-Z_"][\w."]*)\s*=\s*([\s\S]+?)\s*$/)
if (pair) {
assignments[lastIdentifier(pair[1])] = parseValue(pair[2])
}
}
const affected = (table.rows ?? []).filter((row) => rowMatches(row, predicates))
for (const row of affected) {
Object.assign(row, assignments)
}
return returning ? affected : []
}
function applyDelete(
datatable: BenchmarkDatatableSeed,
sql: string
): Record<string, unknown>[] {
const { body, returning } = splitOffReturning(sql)
const match = body.match(/^\s*delete\s+from\s+([a-zA-Z_"][\w."]*)\s*([\s\S]*)$/i)
if (!match) {
return []
}
const table = resolveTable(datatable, match[1])
if (!table) {
return []
}
const whereText = match[2].replace(/^\s*where\s+/i, '').trim() || undefined
const predicates = parsePredicates(whereText)
if (predicates === null) {
return []
}
const rows = table.rows ?? []
const removed = rows.filter((row) => rowMatches(row, predicates))
table.rows = rows.filter((row) => !rowMatches(row, predicates))
return returning ? removed : []
}
// ============= Parsing helpers =============
function resolveTable(
datatable: BenchmarkDatatableSeed,
ref: string
): BenchmarkDatatableTableSeed | undefined {
const { schema, table } = parseRef(ref)
const direct = datatable.schemas[schema]?.[table]
if (direct) {
return direct
}
// Bare table name: fall back to searching every schema for a matching table.
if (!ref.includes('.')) {
for (const tables of Object.values(datatable.schemas)) {
if (tables[table]) {
return tables[table]
}
}
}
return undefined
}
function firstTable(
datatable: BenchmarkDatatableSeed
): BenchmarkDatatableTableSeed | undefined {
for (const tables of Object.values(datatable.schemas)) {
for (const seed of Object.values(tables)) {
return seed
}
}
return undefined
}
function parseRef(ref: string): ParsedRef {
const parts = ref.split('.').map(unquoteIdentifier)
if (parts.length >= 2) {
return { schema: parts[parts.length - 2], table: parts[parts.length - 1] }
}
return { schema: DEFAULT_SCHEMA, table: parts[0] }
}
/** A WHERE clause with no parseable form returns `null`; absent WHERE returns `[]` (match all). */
function parsePredicates(whereText: string | undefined): Predicate[] | null {
if (whereText === undefined || whereText.trim() === '') {
return []
}
const predicates: Predicate[] = []
for (const part of whereText.split(/\s+and\s+/i)) {
const match = part.match(/^\s*([a-zA-Z_"][\w."]*)\s*=\s*([\s\S]+?)\s*$/)
if (!match) {
return null
}
predicates.push({ column: lastIdentifier(match[1]), value: parseValue(match[2]) })
}
return predicates
}
function rowMatches(row: Record<string, unknown>, predicates: Predicate[]): boolean {
return predicates.every((predicate) => looseEquals(row[predicate.column], predicate.value))
}
function looseEquals(left: unknown, right: unknown): boolean {
if (left === null || left === undefined) {
return right === null || right === undefined
}
if (typeof left === 'number' && typeof right === 'number') {
return left === right
}
return String(left) === String(right)
}
function parseValue(raw: string): unknown {
// Drop a trailing Postgres cast (e.g. `2::int4`) before interpreting the literal.
const token = raw.trim().replace(/::\s*[a-zA-Z_][\w]*(\([^)]*\))?\s*$/, '').trim()
const stringMatch = token.match(/^'([\s\S]*)'$/)
if (stringMatch) {
return stringMatch[1].replace(/''/g, "'")
}
if (/^-?\d+(\.\d+)?$/.test(token)) {
return Number(token)
}
if (/^true$/i.test(token)) {
return true
}
if (/^false$/i.test(token)) {
return false
}
if (/^null$/i.test(token)) {
return null
}
return token
}
function splitOffReturning(sql: string): { body: string; returning: boolean } {
const match = maskForClauseScan(sql).match(/\sreturning\s/i)
if (!match || match.index === undefined) {
return { body: sql, returning: false }
}
return { body: sql.slice(0, match.index), returning: true }
}
/**
* A same-length copy of `sql` with the contents of single-quoted strings and
* parenthesized groups blanked to spaces, so a top-level keyword scan
* (WHERE / RETURNING) cannot match inside a string literal or a subquery. Index
* positions in the result map 1:1 back onto the original.
*/
function maskForClauseScan(sql: string): string {
let masked = ''
let depth = 0
let inString = false
for (let i = 0; i < sql.length; i++) {
const char = sql[i]
if (inString) {
if (char === "'") {
if (sql[i + 1] === "'") {
masked += ' '
i++
continue
}
inString = false
}
masked += ' '
continue
}
if (char === "'") {
inString = true
masked += ' '
} else if (char === '(') {
depth++
masked += ' '
} else if (char === ')') {
depth = Math.max(0, depth - 1)
masked += ' '
} else {
masked += depth > 0 ? ' ' : char
}
}
return masked
}
/**
* Inner text of each top-level `( ... )` group in `input`, honoring nested parens
* (e.g. `now()`, `numeric(10,2)`) and single-quoted strings. Used for the CREATE
* column-definition group and INSERT value tuples.
*/
function extractParenGroups(input: string): string[] {
const groups: string[] = []
let depth = 0
let inString = false
let current = ''
for (let i = 0; i < input.length; i++) {
const char = input[i]
if (inString) {
current += char
if (char === "'") {
if (input[i + 1] === "'") {
current += input[++i]
} else {
inString = false
}
}
continue
}
if (char === "'") {
inString = true
current += char
} else if (char === '(') {
depth++
if (depth === 1) {
current = ''
} else {
current += char
}
} else if (char === ')') {
depth = Math.max(0, depth - 1)
if (depth === 0) {
groups.push(current)
current = ''
} else {
current += char
}
} else if (depth > 0) {
current += char
}
}
return groups
}
/** Split on commas that are not inside parentheses or single-quoted strings. */
function splitTopLevel(input: string): string[] {
const parts: string[] = []
let depth = 0
let inString = false
let current = ''
for (let i = 0; i < input.length; i++) {
const char = input[i]
if (inString) {
current += char
if (char === "'") {
if (input[i + 1] === "'") {
current += input[++i]
} else {
inString = false
}
}
continue
}
if (char === "'") {
inString = true
current += char
} else if (char === '(') {
depth++
current += char
} else if (char === ')') {
depth = Math.max(0, depth - 1)
current += char
} else if (char === ',' && depth === 0) {
parts.push(current)
current = ''
} else {
current += char
}
}
if (current.trim() !== '') {
parts.push(current)
}
return parts
}
function isTableConstraint(def: string): boolean {
return /^(primary\s+key|foreign\s+key|constraint|unique|check|exclude|like)\b/i.test(def)
}
function unquoteIdentifier(identifier: string): string {
const trimmed = identifier.trim()
const quoted = trimmed.match(/^"([\s\S]*)"$/)
return quoted ? quoted[1] : trimmed
}
/** For a qualified reference like `orders.id`, keep only the final identifier. */
function lastIdentifier(reference: string): string {
const parts = reference.split('.')
return unquoteIdentifier(parts[parts.length - 1])
}
function stripTrailingSemicolon(sql: string): string {
return sql.replace(/;\s*$/, '')
}
+179 -9
View File
@@ -1,7 +1,14 @@
import { randomUUID } from 'node:crypto'
import type { CompletedJob, Flow, Script } from '../../../frontend/src/lib/gen'
import type { ScriptLang } from '../../../frontend/src/lib/gen/types.gen'
import type { CompletedJob, Flow, Job, Script } from '../../../frontend/src/lib/gen'
import type {
DataTableTables,
DataTableTableSchema,
ScriptLang
} from '../../../frontend/src/lib/gen/types.gen'
import { buildScriptLintResult } from './core/script/preview'
import { applyDatatableSql, type BenchmarkDatatableSeed } from './datatableSqlEngine'
export type { BenchmarkDatatableSeed, BenchmarkDatatableTableSeed } from './datatableSqlEngine'
const BENCHMARK_TIMESTAMP = '1970-01-01T00:00:00.000Z'
@@ -22,17 +29,36 @@ export interface BenchmarkWorkspaceFlow {
value: Flow['value']
}
export interface BenchmarkWorkspaceJob {
/** Stable id so a case prompt can reference a specific run (e.g. for get_job_logs). */
id?: string
jobKind?: CompletedJob['job_kind']
scriptPath?: string
createdBy?: string
label?: string
success?: boolean
logs?: string
}
export interface BenchmarkWorkspaceRunnables {
scripts?: BenchmarkWorkspaceScript[]
flows?: BenchmarkWorkspaceFlow[]
datatables?: BenchmarkDatatableSeed[]
jobs?: BenchmarkWorkspaceJob[]
}
type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' }
const benchmarkWorkspaces = new Set<string>()
const benchmarkWorkspaceRunnables = new Map<string, BenchmarkWorkspaceRunnables>()
// Keyed by `${workspace}::${jobId}` so concurrent attempts (or distinct cases)
// can seed the same fixed job id without clobbering each other's entry.
const benchmarkJobs = new Map<string, { workspace: string; job: BenchmarkCompletedJob }>()
function benchmarkJobKey(workspace: string, jobId: string): string {
return `${workspace}::${jobId}`
}
export function resetBenchmarkMockBackend(): void {
benchmarkWorkspaces.clear()
benchmarkWorkspaceRunnables.clear()
@@ -48,7 +74,25 @@ export function registerBenchmarkWorkspaceRunnables(
runnables: BenchmarkWorkspaceRunnables
): void {
benchmarkWorkspaces.add(workspace)
benchmarkWorkspaceRunnables.set(workspace, runnables)
// Datatables are mutated in place by exec_datatable_sql (a write must be visible
// to later reads), so store an isolated deep copy — never mutate the caller's seed.
benchmarkWorkspaceRunnables.set(workspace, {
...runnables,
datatables: runnables.datatables ? structuredClone(runnables.datatables) : undefined
})
// Seed any fixture jobs so list_runs / get_job_logs have data to return.
for (const seed of runnables.jobs ?? []) {
createBenchmarkCompletedJob({
workspace,
id: seed.id,
jobKind: seed.jobKind ?? 'script',
success: seed.success,
scriptPath: seed.scriptPath,
createdBy: seed.createdBy,
label: seed.label,
logs: seed.logs
})
}
}
export function unregisterBenchmarkWorkspace(workspace: string): void {
@@ -118,14 +162,17 @@ export function createBenchmarkCompletedJob(input: {
scriptPath?: string
scriptHash?: string
args?: Record<string, unknown>
id?: string
createdBy?: string
label?: string
}): string {
const jobId = `benchmark-job-${randomUUID()}`
const jobId = input.id ?? `benchmark-job-${randomUUID()}`
const now = new Date().toISOString()
const job: BenchmarkCompletedJob = {
type: 'CompletedJob',
id: jobId,
workspace_id: input.workspace,
created_by: 'ai-evals',
created_by: input.createdBy ?? 'ai-evals',
created_at: now,
started_at: now,
completed_at: now,
@@ -143,10 +190,11 @@ export function createBenchmarkCompletedJob(input: {
is_skipped: false,
email: 'ai-evals@local',
visible_to_owner: true,
tag: 'benchmark'
tag: 'benchmark',
labels: input.label ? [input.label] : undefined
}
benchmarkJobs.set(jobId, { workspace: input.workspace, job })
benchmarkJobs.set(benchmarkJobKey(input.workspace, jobId), { workspace: input.workspace, job })
return jobId
}
@@ -154,13 +202,135 @@ export function getBenchmarkCompletedJob(
workspace: string,
jobId: string
): BenchmarkCompletedJob | null {
const entry = benchmarkJobs.get(jobId)
if (!entry || entry.workspace !== workspace) {
const entry = benchmarkJobs.get(benchmarkJobKey(workspace, jobId))
if (!entry) {
return null
}
return structuredClone(entry.job)
}
/**
* List seeded/recorded jobs for a benchmark workspace, most recent first —
* the shape `JobService.listJobs` returns. Returns `null` for a non-benchmark
* workspace so the caller can fall through to the real backend. Server-side
* filters (path/creator/status/limit) are intentionally not applied: global
* eval cases assert on the recorded `list_runs` tool call, not on filtering.
*/
export function listBenchmarkJobs(workspace: string): Job[] | null {
if (!hasBenchmarkWorkspace(workspace)) {
return null
}
return [...benchmarkJobs.values()]
.filter((entry) => entry.workspace === workspace)
.map((entry) => structuredClone(entry.job) as Job)
.sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
}
/**
* Mirror `JobService.getJobLogs` (response is the raw log string). Throws a
* "not found" error for an unknown id, matching the backend 404.
*/
export function getBenchmarkJobLogs(workspace: string, jobId: string): string {
const job = getBenchmarkCompletedJob(workspace, jobId)
if (!job) {
throw new Error(`Job Logs not found for "${jobId}"`)
}
return job.logs ?? ''
}
// ============= Datatables (best-effort in-memory SQL) =============
/**
* Project the seeded datatables down to the `list_datatable_tables` response:
* `datatable_name` + `schema -> table_names`, with no column detail.
* Returns `null` for a non-benchmark workspace so callers can fall through to
* the real backend; an empty seed yields `[]`.
*/
export function listBenchmarkDatatables(workspace: string): DataTableTables[] | null {
const runnables = benchmarkWorkspaceRunnables.get(workspace)
if (!runnables) {
return null
}
return (runnables.datatables ?? []).map((datatable) => ({
datatable_name: datatable.datatable_name,
schemas: Object.fromEntries(
Object.entries(datatable.schemas).map(([schema, tables]) => [schema, Object.keys(tables)])
)
}))
}
export function getBenchmarkDatatableSchema(input: {
workspace: string
datatableName: string
schemaName: string
tableName: string
}): DataTableTableSchema {
const runnables = benchmarkWorkspaceRunnables.get(input.workspace)
const datatable = (runnables?.datatables ?? []).find(
(entry) => entry.datatable_name === input.datatableName
)
if (!datatable) {
// Message MUST match the production `isDatatableNotConfiguredError` regex
// (/datatable\s+\S+\s+not found/i in datatableTools.ts) so the
// get_datatable_table_schema not-configured mapping is actually exercised.
throw new Error(`datatable "${input.datatableName}" not found`)
}
const table = datatable.schemas?.[input.schemaName]?.[input.tableName]
if (!table) {
throw new Error(
`table "${input.schemaName}.${input.tableName}" not found in datatable "${input.datatableName}"`
)
}
return {
datatable_name: input.datatableName,
schema_name: input.schemaName,
table_name: input.tableName,
columns: table.columns
}
}
/**
* Execute SQL against a seeded datatable through the best-effort in-memory engine
* (`applyDatatableSql`). Writes (CREATE/INSERT/UPDATE/DELETE/DROP) mutate the
* stored datatable in place so a later list/schema/SELECT reflects them; SELECT
* (and RETURNING) yield rows, other statements yield `[]`. Creates a benchmark
* completed job and returns its id, like `runBenchmarkScriptPreview`.
*/
export function runBenchmarkDatatableSql(input: {
workspace: string
datatableName: string
sql: string
}): string {
const runnables = benchmarkWorkspaceRunnables.get(input.workspace)
const datatable = (runnables?.datatables ?? []).find(
(entry) => entry.datatable_name === input.datatableName
)
const rows = datatable ? applyDatatableSql(datatable, input.sql).rows : []
return createBenchmarkCompletedJob({
workspace: input.workspace,
jobKind: 'preview',
success: true,
args: { database: `datatable://${input.datatableName}` },
result: rows
})
}
/**
* Mirror `JobService.getCompletedJobResultMaybe` for benchmark workspaces — the
* shape `pollJobResult` consumes. The job is created synchronously before
* polling, so it is always present and completed.
*/
export function getBenchmarkCompletedJobResultMaybe(input: {
workspace: string
id: string
}): { success: boolean; completed: boolean; result: unknown } {
const job = getBenchmarkCompletedJob(input.workspace, input.id)
if (!job) {
throw new Error(`Job "${input.id}" not found in benchmark workspace`)
}
return { success: job.success, completed: true, result: job.result }
}
export function runBenchmarkScriptPreview(input: {
workspace: string
requestBody: {
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import {
getBenchmarkCompletedJobResultMaybe,
getBenchmarkDatatableSchema,
listBenchmarkDatatables,
registerBenchmarkWorkspaceRunnables,
resetBenchmarkMockBackend,
runBenchmarkDatatableSql,
type BenchmarkWorkspaceRunnables
} from './mockBackend'
const WORKSPACE = 'benchmark-datatable-ws'
// Mirrors the production `isDatatableNotConfiguredError` regex in
// datatableTools.ts. The schema mock's "not configured" message MUST match it,
// otherwise the not-configured mapping in get_datatable_table_schema is silently
// untested.
const NOT_CONFIGURED_RE = /datatable\s+\S+\s+not found/i
const SEED: BenchmarkWorkspaceRunnables = {
datatables: [
{
datatable_name: 'main',
schemas: {
public: {
orders: {
columns: { id: 'int', total: 'numeric' },
rows: [
{ id: 1, total: 10 },
{ id: 2, total: 20 }
]
},
customers: {
columns: { id: 'int', name: 'text' },
rows: [{ id: 1, name: 'alice' }]
}
}
}
}
]
}
beforeEach(() => resetBenchmarkMockBackend())
afterEach(() => resetBenchmarkMockBackend())
describe('listBenchmarkDatatables', () => {
it('returns null for a non-benchmark workspace (caller falls through to real backend)', () => {
expect(listBenchmarkDatatables('unregistered')).toBeNull()
})
it('returns [] for a registered workspace with no datatables seed', () => {
registerBenchmarkWorkspaceRunnables(WORKSPACE, {})
expect(listBenchmarkDatatables(WORKSPACE)).toEqual([])
})
it('projects seeded datatables to schema -> table names only (no columns)', () => {
registerBenchmarkWorkspaceRunnables(WORKSPACE, SEED)
expect(listBenchmarkDatatables(WORKSPACE)).toEqual([
{ datatable_name: 'main', schemas: { public: ['orders', 'customers'] } }
])
})
})
describe('getBenchmarkDatatableSchema', () => {
beforeEach(() => registerBenchmarkWorkspaceRunnables(WORKSPACE, SEED))
it('returns the columns for a seeded table', () => {
expect(
getBenchmarkDatatableSchema({
workspace: WORKSPACE,
datatableName: 'main',
schemaName: 'public',
tableName: 'orders'
})
).toEqual({
datatable_name: 'main',
schema_name: 'public',
table_name: 'orders',
columns: { id: 'int', total: 'numeric' }
})
})
it('throws a not-configured error matching the production regex for an unknown datatable', () => {
let error: Error | undefined
try {
getBenchmarkDatatableSchema({
workspace: WORKSPACE,
datatableName: 'ghost',
schemaName: 'public',
tableName: 'orders'
})
} catch (e) {
error = e as Error
}
expect(error).toBeDefined()
expect(error!.message).toMatch(NOT_CONFIGURED_RE)
})
it('throws a table-not-found error that does NOT match the datatable-not-configured regex', () => {
// The datatable IS configured; only the table is missing. Production maps
// this to a generic "error getting schema", not the blocking message.
let error: Error | undefined
try {
getBenchmarkDatatableSchema({
workspace: WORKSPACE,
datatableName: 'main',
schemaName: 'public',
tableName: 'ghost'
})
} catch (e) {
error = e as Error
}
expect(error).toBeDefined()
expect(error!.message).not.toMatch(NOT_CONFIGURED_RE)
})
})
describe('runBenchmarkDatatableSql + getBenchmarkCompletedJobResultMaybe', () => {
beforeEach(() => registerBenchmarkWorkspaceRunnables(WORKSPACE, SEED))
function exec(sql: string): { success: boolean; completed: boolean; result: unknown } {
const jobId = runBenchmarkDatatableSql({ workspace: WORKSPACE, datatableName: 'main', sql })
return getBenchmarkCompletedJobResultMaybe({ workspace: WORKSPACE, id: jobId })
}
it('returns the canned rows of the table named in a SELECT FROM clause', () => {
expect(exec('SELECT * FROM customers')).toEqual({
success: true,
completed: true,
result: [{ id: 1, name: 'alice' }]
})
})
it('falls back to the first seeded table when the SELECT references no known table', () => {
expect(exec('select 1').result).toEqual([
{ id: 1, total: 10 },
{ id: 2, total: 20 }
])
})
it('returns [] success for DDL and DML statements without RETURNING', () => {
expect(exec('CREATE TABLE foo (id int)').result).toEqual([])
expect(exec('INSERT INTO orders VALUES (3, 30)').result).toEqual([])
expect(exec('update orders set total = 0').result).toEqual([])
})
it('reflects a write in a later SELECT, isolated from the shared seed', () => {
exec('UPDATE orders SET total = 999 WHERE id = 1')
expect((exec('SELECT * FROM orders').result as Record<string, unknown>[])).toContainEqual({
id: 1,
total: 999
})
// Registration deep-clones the seed, so the shared SEED const stays pristine.
expect(SEED.datatables![0].schemas.public.orders.rows).toContainEqual({ id: 1, total: 10 })
})
it('reflects a CREATE in list_datatables and get_datatable_table_schema', () => {
exec('CREATE TABLE public.refunds (order_id int4, amount numeric)')
expect(listBenchmarkDatatables(WORKSPACE)?.[0].schemas.public).toContain('refunds')
expect(
getBenchmarkDatatableSchema({
workspace: WORKSPACE,
datatableName: 'main',
schemaName: 'public',
tableName: 'refunds'
}).columns
).toEqual({ order_id: 'int4', amount: 'numeric' })
})
it('throws for an unknown job id', () => {
expect(() =>
getBenchmarkCompletedJobResultMaybe({ workspace: WORKSPACE, id: 'does-not-exist' })
).toThrow()
})
})
@@ -34,15 +34,21 @@ vi.mock('$lib/gen', async () => {
const actual = await vi.importActual<any>('$lib/gen')
const {
getBenchmarkCompletedJob,
getBenchmarkCompletedJobResultMaybe,
getBenchmarkDatatableSchema,
getBenchmarkFlowByPath,
getBenchmarkJobLogs,
getBenchmarkScriptByHash,
getBenchmarkScriptByPath,
hasBenchmarkWorkspace,
listBenchmarkDatatables,
listBenchmarkFlows,
listBenchmarkJobs,
listBenchmarkScripts,
createBenchmarkHttpTrigger,
createBenchmarkSchedule,
previewBenchmarkSchedule,
runBenchmarkDatatableSql,
runBenchmarkFlowByPath,
runBenchmarkScriptPreview
} = await import('./mockBackend')
@@ -79,6 +85,16 @@ vi.mock('$lib/gen', async () => {
}
return actual.ScriptService.getScriptByPath(data)
},
getScriptByPathWithDraft: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const script = getBenchmarkScriptByPath(data.workspace, data.path)
if (!script) {
throw new Error(`Script "${data.path}" not found in benchmark workspace`)
}
return script
}
return actual.ScriptService.getScriptByPathWithDraft(data)
},
getScriptByHash: async (data: { workspace: string; hash: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const script = getBenchmarkScriptByHash(data.workspace, data.hash)
@@ -108,6 +124,26 @@ vi.mock('$lib/gen', async () => {
return flow
}
return actual.FlowService.getFlowByPath(data)
},
getFlowByPathWithDraft: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const flow = getBenchmarkFlowByPath(data.workspace, data.path)
if (!flow) {
throw new Error(`Flow "${data.path}" not found in benchmark workspace`)
}
return flow
}
return actual.FlowService.getFlowByPathWithDraft(data)
},
getFlowLatestVersion: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const flow = getBenchmarkFlowByPath(data.workspace, data.path)
if (!flow) {
throw new Error(`Flow "${data.path}" not found in benchmark workspace`)
}
return { id: 1 }
}
return actual.FlowService.getFlowLatestVersion(data)
}
}),
JobService: wrapService(actual.JobService, {
@@ -119,13 +155,27 @@ vi.mock('$lib/gen', async () => {
args?: Record<string, unknown>
path?: string
}
}) =>
hasBenchmarkWorkspace(data.workspace)
? runBenchmarkScriptPreview({
workspace: data.workspace,
requestBody: data.requestBody ?? {}
})
: actual.JobService.runScriptPreview(data),
}) => {
if (!hasBenchmarkWorkspace(data.workspace)) {
return actual.JobService.runScriptPreview(data)
}
const requestBody = data.requestBody ?? {}
const database = requestBody.args?.database
// Datatable SQL runs as a `postgresql` preview against `datatable://<name>`.
// Execute it through the canned-SQL mock instead of linting it as a script.
if (
requestBody.language === 'postgresql' &&
typeof database === 'string' &&
database.startsWith('datatable://')
) {
return runBenchmarkDatatableSql({
workspace: data.workspace,
datatableName: database.slice('datatable://'.length),
sql: requestBody.content ?? ''
})
}
return runBenchmarkScriptPreview({ workspace: data.workspace, requestBody })
},
runFlowByPath: async (data: {
workspace: string
path: string
@@ -147,7 +197,39 @@ vi.mock('$lib/gen', async () => {
return job
}
return actual.JobService.getJob(data)
}
},
getCompletedJobResultMaybe: async (data: { workspace: string; id: string }) =>
hasBenchmarkWorkspace(data.workspace)
? getBenchmarkCompletedJobResultMaybe({ workspace: data.workspace, id: data.id })
: actual.JobService.getCompletedJobResultMaybe(data),
listJobs: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkJobs(data.workspace) ?? [])
: actual.JobService.listJobs(data),
getJobLogs: async (data: { workspace: string; id: string }) =>
hasBenchmarkWorkspace(data.workspace)
? getBenchmarkJobLogs(data.workspace, data.id)
: actual.JobService.getJobLogs(data)
}),
WorkspaceService: wrapService(actual.WorkspaceService, {
listDataTableTables: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkDatatables(data.workspace) ?? [])
: actual.WorkspaceService.listDataTableTables(data),
getDataTableTableSchema: async (data: {
workspace: string
datatableName: string
schemaName: string
tableName: string
}) =>
hasBenchmarkWorkspace(data.workspace)
? getBenchmarkDatatableSchema({
workspace: data.workspace,
datatableName: data.datatableName,
schemaName: data.schemaName,
tableName: data.tableName
})
: actual.WorkspaceService.getDataTableTableSchema(data)
}),
ScheduleService: wrapService(actual.ScheduleService, {
existsSchedule: async (data: { workspace: string; path: string }) =>
+11
View File
@@ -8,6 +8,9 @@
args:
a: 4
b: 5
toolExpect:
requiredToolsUsed:
- test_run_flow
judgeChecklist:
- "the flow takes `a` and `b` as inputs"
- "the main step is named `sum_numbers`"
@@ -25,6 +28,9 @@
args:
a: 2
b: 3
toolExpect:
requiredToolsUsed:
- test_run_flow
judgeChecklist:
- "the flow takes `a` and `b` as inputs"
- "the main step is named `sum_numbers`"
@@ -42,6 +48,9 @@
args:
a: 7
b: 8
toolExpect:
requiredToolsUsed:
- test_run_flow
judgeChecklist:
- "the parent flow takes `a` and `b` as inputs"
- "the main step is named `call_add_numbers`"
@@ -426,6 +435,7 @@
- return_schedule_status
toolExpect:
requiredToolsUsed:
- test_run_flow
- create_schedule
toolCallArgs:
- tool: create_schedule
@@ -453,6 +463,7 @@
- webhook_response
toolExpect:
requiredToolsUsed:
- test_run_flow
- create_trigger
toolCallArgs:
- tool: create_trigger
+783
View File
@@ -87,3 +87,786 @@
- the flow accepts numeric inputs a and b
- the flow returns the sum of a and b
- the result stays as an AI draft and is not deployed or saved to the workspace
- id: global-test4-multi-artifact-notification-job
prompt: |-
Set up a draft stale-trial notification job.
Create a Bun script at `f/evals/global/check_stale_trials` that accepts `max_age_days`, uses mocked inline trial account data, and returns the stale trial account IDs.
Also create a weekday 09:00 UTC schedule at `f/evals/global/check_stale_trials_weekday` for that script with `max_age_days` set to 14.
Add an HTTP POST trigger at `f/evals/global/check_stale_trials_manual` with route path `evals/check-stale-trials` that runs the same script manually.
Leave everything as AI drafts only; do not deploy or save anything to the workspace.
runtime:
maxTurns: 12
validate:
draftCountExactly: 3
requiredDrafts:
- type: script
path: f/evals/global/check_stale_trials
language: bun
valueIncludes:
- max_age_days
- trial
- type: schedule
path: f/evals/global/check_stale_trials_weekday
valueIncludes:
- f/evals/global/check_stale_trials
- UTC
- "14"
- type: trigger
triggerKind: http
path: f/evals/global/check_stale_trials_manual
valueIncludes:
- evals/check-stale-trials
- f/evals/global/check_stale_trials
toolExpect:
requiredToolsUsed:
- write_script
- write_schedule
- write_trigger
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates a Bun script draft for stale trial accounts
- creates a weekday 09:00 UTC schedule draft for the script with max_age_days set to 14
- creates an HTTP POST trigger draft with route path evals/check-stale-trials for the same script
- leaves all artifacts as drafts only and does not deploy
- id: global-test5-existing-flow-inline-code-edit
prompt: |-
Update the existing flow at `f/evals/global/process_invoice`.
Only change the `calculate_total` inline code so it applies 8% tax and returns an object containing `subtotal`, `tax`, and `total`.
Leave the updated flow as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
path: f/evals/global/process_invoice
valueIncludes:
- calculate_total
- tax
- total
toolExpect:
requiredToolsUsed:
- read_workspace_item
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- reads the existing process_invoice flow before editing it
- updates the calculate_total inline code to apply 8% tax
- returns subtotal, tax, and total from the updated flow logic
- leaves the result as an AI draft only
- id: global-test6-secret-variable-draft
prompt: |-
Create a secret variable draft at `f/evals/global/slack_bot_token`.
Use the placeholder value `xoxb-redacted-test-token` and description `Slack bot token for eval notifications`.
Do not create any resource or deploy anything.
runtime:
maxTurns: 6
validate:
draftCountExactly: 1
requiredDrafts:
- type: variable
path: f/evals/global/slack_bot_token
valueIncludes:
- Slack bot token
- "true"
forbiddenDrafts:
- type: resource
path: f/evals/global/slack_bot_token
toolExpect:
requiredToolsUsed:
- write_variable
forbiddenToolsUsed:
- write_resource
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_variable
field: value
stringStartsWithAnyOf:
- xoxb-redacted-test-token
skipJudge: true
judgeChecklist:
- creates exactly one secret variable draft at f/evals/global/slack_bot_token
- uses the requested placeholder value and description
- does not create a resource or deploy anything
- id: global-test7-ambiguous-app-asks-question
prompt: |-
Create a new raw app for triaging support tickets.
runtime:
maxTurns: 4
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- askUserQuestion
forbiddenToolsUsed:
- init_app
- write_app_file
- write_app_runnable
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
- id: global-test8-human-script-infer-path-language
prompt: |-
I need a small helper that formats a customer-facing welcome line.
It should take a person's name and return "Welcome aboard, <name>!".
Please just stage it as a draft for now.
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
valueIncludes:
- Welcome aboard
- name
toolExpect:
requiredToolsUsed:
- write_script
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates a single script draft for a welcome-line helper
- accepts a person's name as input
- returns a message containing Welcome aboard, the provided name, and an exclamation mark
- chooses a reasonable workspace path and script language without needing the user to specify them
- leaves the result as an AI draft only
- id: global-test9-human-weekday-trial-job
prompt: |-
Can you set up a draft daily job that checks a few hard-coded trial accounts and returns the ones whose trial has ended?
It should run every weekday morning around 9 in UTC with a 30 day cutoff.
Keep it as draft work only.
runtime:
maxTurns: 10
validate:
draftCountExactly: 2
requiredDrafts:
- type: script
pathIncludes:
- trial
valueIncludes:
- trial
- "30"
- type: schedule
pathIncludes:
- trial
valueIncludes:
- UTC
toolExpect:
requiredToolsUsed:
- write_script
- write_schedule
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates a script draft that checks hard-coded trial accounts
- returns the accounts whose trial has ended based on a 30 day cutoff
- creates a schedule draft for weekday mornings around 09:00 UTC
- links the schedule to the generated script
- leaves both artifacts as drafts only
- id: global-test10-human-secret-variable
prompt: |-
I need a placeholder Slack bot token stored securely for future notification work.
Use xoxb-redacted-test-token and note that it is for eval notifications.
Only prepare a draft.
runtime:
maxTurns: 6
validate:
draftCountExactly: 1
requiredDrafts:
- type: variable
pathIncludes:
- slack
valueIncludes:
- eval notifications
- "true"
toolExpect:
requiredToolsUsed:
- write_variable
forbiddenToolsUsed:
- write_resource
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_variable
field: value
stringStartsWithAnyOf:
- xoxb-redacted-test-token
skipJudge: true
judgeChecklist:
- creates a single secret variable draft for the Slack bot token placeholder
- uses the requested placeholder value
- includes a note or description that it is for eval notifications
- does not create a resource or deploy anything
- id: global-test11-human-existing-flow-informal-edit
prompt: |-
There is an invoice processing flow in this workspace.
Can you adjust its total calculation so it adds 8% tax and returns subtotal, tax, and total?
Keep the change as a draft.
initial: ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
pathIncludes:
- invoice
valueIncludes:
- calculate_total
- tax
- total
toolExpect:
requiredToolsUsed:
- read_workspace_item
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- finds and edits the existing invoice processing flow without the user providing its exact path
- updates the total calculation to apply 8% tax
- returns subtotal, tax, and total from the updated flow logic
- leaves the result as an AI draft only
- id: global-test12-current-live-script-edit
prompt: |-
The script I have open formats greetings.
Can you update this script so it uppercases the name before greeting them and ends with an exclamation mark?
Keep it as draft work.
initial: ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/current_greeting
language: bun
valueIncludes:
- toUpperCase
- "!"
forbiddenDrafts:
- type: script
path: f/evals/global/format_greeting
- type: script
path: f/evals/global/format_greeting_archive
toolExpect:
requiredToolsUsed:
- read_workspace_item
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- resolves "this script" to the active live editor script instead of another similarly named workspace script
- updates the greeting logic to uppercase the provided name
- returns a greeting ending with an exclamation mark
- leaves the result as a draft only
- id: global-test13-current-live-flow-edit
prompt: |-
I have the invoice flow open.
In the current flow, update the total calculation to add 8% tax and return subtotal, tax, and total.
Keep the change as a draft.
initial: ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
path: f/evals/global/current_invoice_flow
valueIncludes:
- calculate_total
- tax
- total
forbiddenDrafts:
- type: flow
path: f/evals/global/process_invoice
- type: flow
path: f/evals/global/process_refund
toolExpect:
requiredToolsUsed:
- read_workspace_item
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- resolves "current flow" to the active live editor flow
- does not edit the similarly named deployed invoice or refund flows
- updates the calculate_total logic to apply 8% tax
- returns subtotal, tax, and total from the updated flow logic
- leaves the result as a draft only
- id: global-test14-current-without-live-editor-asks-question
prompt: |-
Please update this script so it returns `ok`.
Keep it as a draft.
runtime:
maxTurns: 4
validate:
draftCountExactly: 0
toolExpect:
forbiddenToolsUsed:
- write_script
- edit_script
- write_flow
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- asks which script to update when the user refers to "this script" without selected or active editor context
- does not guess a path or create a new script draft
- id: global-test15-human-postgres-resource
prompt: |-
I'm wiring the eval reporting database into this workspace.
Can you stage a Postgres connection for it in the shared evals/global folder?
Use host `reports-db.internal`, port 5432, database `evals_reporting`, user `report_reader`, and password `pg-redacted-reporting-password`.
Keep the credentials safe.
This is just draft work for now.
runtime:
maxTurns: 10
validate:
draftCountExactly: 2
requiredDrafts:
- type: variable
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- report
- password
valueIncludes:
- "true"
- report
- type: resource
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- report
valueIncludes:
- postgres
- reports-db.internal
- "5432"
- evals_reporting
- report_reader
- "$var:"
valueExcludes:
- pg-redacted-reporting-password
toolExpect:
requiredToolsUsed:
- write_variable
- search_resource_types
- write_resource
forbiddenToolsUsed:
- write_schedule
- write_trigger
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_variable
field: value
stringStartsWithAnyOf:
- pg-redacted-reporting-password
skipJudge: true
judgeChecklist:
- creates a Postgres resource draft for the eval reporting database
- creates a secret variable draft for the database password
- puts the drafts in sensible eval/global reporting-related paths
- uses the requested host, port, database, and user
- references the secret variable from the resource instead of embedding the password
- leaves the work as a draft only
- id: global-test16-human-visible-variable
prompt: |-
We keep reusing a 30 day trial cutoff in eval notification jobs.
Can you stage that as a normal workspace variable in the shared evals/global folder, with a short description so people know what it controls?
It is not a secret.
runtime:
maxTurns: 6
validate:
draftCountExactly: 1
requiredDrafts:
- type: variable
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- trial
valueIncludes:
- "30"
- "false"
- trial
toolExpect:
requiredToolsUsed:
- write_variable
forbiddenToolsUsed:
- write_resource
- write_schedule
- write_trigger
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates exactly one non-secret variable draft for the trial cutoff
- stores the value 30
- chooses a sensible eval/global path related to trials or notifications
- includes a useful description of what the value controls
- does not create resources, schedules, triggers, or deployed workspace changes
- id: global-test17-human-schedule-existing-helper
prompt: |-
The workspace already has a report digest helper.
Can you stage a weekday 8:30 AM UTC run for it with `dry_run` turned on?
I only want the schedule draft for review.
initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: schedule
pathIncludes:
- digest
valueIncludes:
- f/evals/global/send_report_digest
- UTC
- dry_run
- "true"
toolExpect:
requiredToolsUsed:
- list_workspace_items
- write_schedule
forbiddenToolsUsed:
- write_script
- write_flow
- write_resource
- write_variable
- write_trigger
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- finds the existing report digest helper rather than creating a new script or flow
- creates one schedule draft for that helper
- schedules it for weekdays around 08:30 UTC
- passes dry_run as true
- leaves only the schedule draft for review
- id: global-test18-human-slack-resource-with-secret
prompt: |-
I'm preparing Slack notifications for eval failures.
Can you stage a Slack connection in the shared evals/global folder?
The bot token is `xoxb-redacted-test-token`; keep it safe.
Don't deploy anything yet.
runtime:
maxTurns: 8
validate:
draftCountExactly: 2
requiredDrafts:
- type: variable
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- slack
- token
valueIncludes:
- "true"
- type: resource
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- slack
valueIncludes:
- slack
- "$var:"
valueExcludes:
- xoxb-redacted-test-token
toolExpect:
requiredToolsUsed:
- write_variable
- search_resource_types
- write_resource
forbiddenToolsUsed:
- write_schedule
- write_trigger
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_variable
field: value
stringStartsWithAnyOf:
- xoxb-redacted-test-token
skipJudge: true
judgeChecklist:
- creates a secret variable draft for the Slack bot token placeholder
- creates a Slack resource draft that references the secret variable instead of embedding the token
- keeps both drafts under a sensible eval/global Slack-related path
- does not create schedules, triggers, or deployed workspace changes
- id: global-test19-datatable-not-configured-asks-to-set-up
prompt: |-
Here are two newsletter signups: alice@example.com and bob@example.com.
Save them into a workspace data table for me.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- list_datatables
forbiddenToolsUsed:
- exec_datatable_sql
- write_script
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- checks which data tables exist in the workspace before acting
- recognizes that no data table is configured in this workspace
- explains that a data table must first be set up by the user in the workspace settings (Data Tables) and is not created via SQL
- does not run SQL, write a script, or invent a data table to work around the missing configuration
- tells the user to configure a data table and then try again
- id: global-test20-datatable-no-hallucinated-main
prompt: |-
Pull the latest rows from the orders table in our data table so I can see recent orders.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- list_datatables
forbiddenToolsUsed:
- exec_datatable_sql
- write_script
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- checks which data tables exist in the workspace before querying
- recognizes that no data table is configured in this workspace
- does not assume a data table named "main" (or any other name) exists
- does not run SQL against a guessed data table or fabricate order rows
- tells the user they need to set up a data table in the workspace settings first
- id: global-test21-datatable-list-summarize
prompt: |-
What tables do we have in our workspace data table? Just give me the list.
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- list_datatables
forbiddenToolsUsed:
- get_datatable_table_schema
- exec_datatable_sql
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- lists the tables available in the workspace data table (orders and customers)
- answers from the data table listing rather than fabricating table names
- does not fetch column details or run SQL just to produce a table list
- id: global-test22-datatable-inspect-columns
prompt: |-
What columns does the orders table have in our workspace data table?
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- get_datatable_table_schema
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- inspects the orders table schema in the workspace data table
- reports the orders columns (such as id, customer_id, total, status, created_at)
- answers from the retrieved schema rather than guessing the columns
- id: global-test23-datatable-query-select
prompt: |-
Show me the orders in our workspace data table, including their status and total.
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- exec_datatable_sql
forbiddenToolsUsed:
- write_script
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: exec_datatable_sql
field: sql
stringIncludesAnyOf:
- select
skipJudge: true
judgeChecklist:
- runs a SELECT query against the orders table in the workspace data table
- reports the orders returned by the query back to the user instead of fabricating data
- does not tell the user to set up a data table, since one already exists
- id: global-test24-datatable-create-table
prompt: |-
Add a new table called refunds to our workspace data table, with an order id and a refund amount.
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- exec_datatable_sql
forbiddenToolsUsed:
- write_script
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: exec_datatable_sql
field: sql
stringIncludesAnyOf:
- create table
skipJudge: true
judgeChecklist:
- creates the refunds table with a plain CREATE TABLE statement on the data table
- includes an order id and a refund amount column
- treats creating the table as a normal SQL statement and does not claim a separate registration step is needed
- does not write a script to create the table
- id: global-test25-datatable-mutate-rows
prompt: |-
Mark order number 2 as shipped in our workspace data table.
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
runtime:
# Headroom for inspect-schema -> UPDATE -> verify; the in-memory engine now
# persists the write, so verification confirms on the first try (no loop).
maxTurns: 12
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- exec_datatable_sql
forbiddenToolsUsed:
- write_script
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: exec_datatable_sql
field: sql
stringIncludesAnyOf:
- update
- insert into
skipJudge: true
judgeChecklist:
- runs an UPDATE on the orders table setting the status of order id 2 to shipped
- targets only order number 2 rather than rewriting the whole table
- confirms the change back to the user
- id: global-test26-datatable-script-sdk
prompt: |-
Write a script that reads our workspace data table and returns the total revenue across all orders.
Leave it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
valueIncludes:
- wmill.datatable(
toolExpect:
requiredToolsUsed:
- get_instructions
- write_script
forbiddenToolsUsed:
- exec_datatable_sql
- deploy_workspace_item
- delete_workspace_item
# The judge has no datatable SDK reference and wrongly penalizes correct
# wmill.datatable() tagged-template usage, so rely on the deterministic checks:
# required get_instructions + write_script, forbidden exec_datatable_sql, and a
# draft that contains wmill.datatable(.
skipJudge: true
judgeChecklist:
- writes a script (not a chat-time SQL execution) that reads the workspace data table at runtime
- uses the wmill.datatable() SDK to query the orders table and sum the order totals
- returns the total revenue from the script
- leaves the result as an AI draft and does not deploy or save it
- id: global-test27-list-recent-runs
prompt: |-
What are the most recent runs in this workspace? Give me a quick rundown of what ran and whether it succeeded.
initial: ai_evals/fixtures/frontend/global/initial/jobs_seed.json
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- list_runs
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
- write_script
# Read-only job inspection produces no draft, so the global judge (which only
# sees the drafts artifact) would score it empty — validate via tool use.
skipJudge: true
judgeChecklist:
- lists the recent runs from the workspace rather than fabricating them
- summarizes each run's path and success/failure from the returned data
- id: global-test28-fetch-failed-run-logs
prompt: |-
The run with id 01920000-0000-7000-8000-0000000000f1 failed. Pull its logs and tell me what went wrong.
initial: ai_evals/fixtures/frontend/global/initial/jobs_seed.json
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- get_job_logs
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
- write_script
toolCallArgs:
- tool: get_job_logs
field: id
stringIncludesAnyOf:
- 01920000-0000-7000-8000-0000000000f1
# Same as above — no draft is produced, so rely on the deterministic tool-use
# and argument checks rather than the judge.
skipJudge: true
judgeChecklist:
- fetches the logs for the requested job id
- explains the failure from the returned logs (connection refused to the upstream API)
+5
View File
@@ -5,6 +5,9 @@
Keep it simple and do not add external dependencies.
initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- test_run_script
judgeChecklist:
- uses the existing `name` input
- returns a plain greeting string
@@ -20,6 +23,7 @@
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- test_run_script
- create_schedule
toolCallArgs:
- tool: create_schedule
@@ -44,6 +48,7 @@
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- test_run_script
- create_trigger
toolCallArgs:
- tool: create_trigger
+7 -3
View File
@@ -211,7 +211,7 @@ async function handleRun(input: {
const summaries: Array<{
label: string;
passRate: number;
averageDurationMs: number;
averagePassedDurationMs: number | null;
}> = [];
for (const [index, model] of models.entries()) {
@@ -259,7 +259,7 @@ async function handleRun(input: {
summaries.push({
label: `${model.id} (${runModel})`,
passRate: result.passRate,
averageDurationMs: result.averageDurationMs,
averagePassedDurationMs: result.averagePassedDurationMs ?? null,
});
}
@@ -267,7 +267,7 @@ async function handleRun(input: {
process.stdout.write("\nModel summary\n");
for (const summary of summaries) {
process.stdout.write(
`- ${summary.label}: ${formatPercent(summary.passRate)} | ${Math.round(summary.averageDurationMs)}ms\n`,
`- ${summary.label}: ${formatPercent(summary.passRate)} | passed avg ${formatNullableDuration(summary.averagePassedDurationMs)}\n`,
);
}
}
@@ -351,6 +351,10 @@ function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatNullableDuration(value: number | null): string {
return value === null ? "n/a" : `${Math.round(value)}ms`;
}
void main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
+44 -1
View File
@@ -14,6 +14,21 @@ describe("loadCases", () => {
},
},
});
expect(caseEntry?.toolExpect).toEqual({
requiredToolsUsed: ["test_run_flow"],
});
});
it("loads script and flow test tool expectations", async () => {
const scriptCases = await loadCases("script");
const flowCases = await loadCases("flow");
expect(scriptCases.find((entry) => entry.id === "script-test1-greet-user")?.toolExpect).toEqual({
requiredToolsUsed: ["test_run_script"],
});
expect(flowCases.find((entry) => entry.id === "flow-test0-sum-two-numbers")?.toolExpect).toEqual({
requiredToolsUsed: ["test_run_flow"],
});
});
it("loads the workspace-flow preference benchmark case", async () => {
@@ -203,6 +218,34 @@ describe("loadCases", () => {
});
});
it("loads global active-editor eval cases", async () => {
const globalCases = await loadCases("global");
const scriptCase = globalCases.find(
(entry) => entry.id === "global-test12-current-live-script-edit"
);
const flowCase = globalCases.find(
(entry) => entry.id === "global-test13-current-live-flow-edit"
);
expect(scriptCase?.initialPath).toContain(
"ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json"
);
expect(scriptCase?.toolExpect).toMatchObject({
requiredToolsUsed: ["read_workspace_item"],
});
expect(flowCase?.initialPath).toContain(
"ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json"
);
expect(flowCase?.validate).toMatchObject({
requiredDrafts: [
{
type: "flow",
path: "f/evals/global/current_invoice_flow",
},
],
});
});
it("loads tool expectations for workspace mutation cases", async () => {
const scriptCases = await loadCases("script");
const caseEntry = scriptCases.find(
@@ -210,7 +253,7 @@ describe("loadCases", () => {
);
expect(caseEntry?.toolExpect).toEqual({
requiredToolsUsed: ["create_schedule"],
requiredToolsUsed: ["test_run_script", "create_schedule"],
toolCallArgs: [
{
tool: "create_schedule",
+36 -14
View File
@@ -2,28 +2,50 @@ import { describe, expect, it } from "bun:test";
import { resolveEvalModel } from "./models";
describe("resolveEvalModel", () => {
it("supports GPT-5.5 aliases for frontend evals", () => {
expect(resolveEvalModel("flow", "gpt-5.5").frontend).toEqual({
provider: "openai",
model: "gpt-5.5",
});
expect(resolveEvalModel("app", "gpt-55").frontend).toEqual({
provider: "openai",
model: "gpt-5.5",
});
expect(resolveEvalModel("script", "5.5").frontend).toEqual({
provider: "openai",
model: "gpt-5.5",
});
});
it("supports Gemini aliases for frontend evals", () => {
expect(resolveEvalModel("flow", "gemini").frontend).toEqual({
provider: "googleai",
model: "gemini-2.5-flash",
});
expect(resolveEvalModel("app", "gemini-pro").frontend).toEqual({
provider: "googleai",
model: "gemini-2.5-pro",
});
expect(resolveEvalModel("script", "gemini-3-flash-preview").frontend).toEqual({
expect(
resolveEvalModel("script", "gemini-3-flash-preview").frontend,
).toEqual({
provider: "googleai",
model: "gemini-3-flash-preview",
});
expect(resolveEvalModel("flow", "gemini-3.1-pro-preview").frontend).toEqual({
provider: "googleai",
model: "gemini-3.1-pro-preview",
expect(resolveEvalModel("flow", "gemini-3.1-pro-preview").frontend).toEqual(
{
provider: "googleai",
model: "gemini-3.1-pro-preview",
},
);
});
it("supports DeepSeek aliases for frontend evals", () => {
expect(resolveEvalModel("flow", "deepseek").frontend).toEqual({
provider: "deepseek",
model: "deepseek-v4-flash",
});
expect(resolveEvalModel("script", "deepseek-v4-pro").frontend).toEqual({
provider: "deepseek",
model: "deepseek-v4-pro",
});
});
it("rejects Gemini aliases for cli evals", () => {
expect(() => resolveEvalModel("cli", "gemini")).toThrow(
"Model gemini-flash is not supported for cli mode"
expect(() => resolveEvalModel("cli", "gemini-3-flash-preview")).toThrow(
"Model gemini-3-flash-preview is not supported for cli mode",
);
});
});
+43 -20
View File
@@ -1,7 +1,7 @@
import type { EvalMode } from "./types";
export interface FrontendEvalModelConfig {
provider: "anthropic" | "openai" | "googleai";
provider: "anthropic" | "openai" | "googleai" | "deepseek";
model: string;
}
@@ -88,21 +88,12 @@ export const EVAL_MODELS: EvalModelSpec[] = [
},
},
{
id: "gemini-flash",
label: "Gemini 2.5 Flash",
aliases: ["gemini", "gemini-flash", "gemini-2.5-flash"],
id: "gpt-5.5",
label: "GPT-5.5",
aliases: ["gpt-5.5", "gpt-55", "5.5"],
frontend: {
provider: "googleai",
model: "gemini-2.5-flash",
},
},
{
id: "gemini-pro",
label: "Gemini 2.5 Pro",
aliases: ["gemini-pro", "gemini-2.5-pro"],
frontend: {
provider: "googleai",
model: "gemini-2.5-pro",
provider: "openai",
model: "gpt-5.5",
},
},
{
@@ -117,15 +108,40 @@ export const EVAL_MODELS: EvalModelSpec[] = [
{
id: "gemini-3.1-pro-preview",
label: "Gemini 3.1 Pro Preview",
aliases: ["gemini-3.1-pro-preview", "gemini-3.1-pro", "gemini-3-pro-preview"],
aliases: [
"gemini-3.1-pro-preview",
"gemini-3.1-pro",
"gemini-3-pro-preview",
],
frontend: {
provider: "googleai",
model: "gemini-3.1-pro-preview",
},
},
{
id: "deepseek-v4-flash",
label: "DeepSeek V4 Flash",
aliases: ["deepseek", "deepseek-v4", "deepseek-v4-flash"],
frontend: {
provider: "deepseek",
model: "deepseek-v4-flash",
},
},
{
id: "deepseek-v4-pro",
label: "DeepSeek V4 Pro",
aliases: ["deepseek-pro", "deepseek-v4-pro"],
frontend: {
provider: "deepseek",
model: "deepseek-v4-pro",
},
},
];
export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec {
export function resolveEvalModel(
mode: EvalMode,
alias?: string,
): EvalModelSpec {
const spec = alias ? findEvalModel(alias) : getDefaultEvalModel(mode);
if (!spec) {
throw new Error(`Unknown model: ${alias}`);
@@ -152,14 +168,19 @@ export function getEvalModelHelpText(): string {
}).join("\n");
}
export function formatRunModelLabel(mode: EvalMode, model: EvalModelSpec): string {
export function formatRunModelLabel(
mode: EvalMode,
model: EvalModelSpec,
): string {
if (mode === "cli") {
return `${model.cli!.provider}:${model.cli!.model}`;
}
return `${model.frontend!.provider}:${model.frontend!.model}`;
}
export function getFrontendEvalModel(model: EvalModelSpec): FrontendEvalModelConfig {
export function getFrontendEvalModel(
model: EvalModelSpec,
): FrontendEvalModelConfig {
if (!model.frontend) {
throw new Error(`Model ${model.id} does not support frontend evals`);
}
@@ -180,6 +201,8 @@ function getDefaultEvalModel(mode: EvalMode): EvalModelSpec {
function findEvalModel(alias: string): EvalModelSpec | undefined {
const normalized = alias.trim().toLowerCase();
return EVAL_MODELS.find((model) =>
[model.id, ...model.aliases].some((candidate) => candidate.toLowerCase() === normalized)
[model.id, ...model.aliases].some(
(candidate) => candidate.toLowerCase() === normalized,
),
);
}
+242
View File
@@ -0,0 +1,242 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "bun:test";
import {
appendHistoryRecord,
buildRunResult,
formatRunSummary,
} from "./results";
import type { BenchmarkCaseResult } from "./types";
function caseResult(
attempts: BenchmarkCaseResult["attempts"],
): BenchmarkCaseResult {
return {
id: "case-1",
prompt: "Do the thing",
attempts,
};
}
describe("benchmark results", () => {
it("keeps success cost metrics separate from failed attempts", () => {
const result = buildRunResult({
mode: "global",
runs: 1,
runModel: "model-under-test",
judgeModel: "judge-model",
caseResults: [
caseResult([
{
attempt: 1,
passed: true,
durationMs: 1000,
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["edit_script"],
skillsInvoked: [],
checks: [{ name: "edited", passed: true }],
judgeScore: 100,
judgeSummary: "ok",
error: null,
tokenUsage: { prompt: 100, completion: 20, total: 120 },
},
{
attempt: 2,
passed: false,
durationMs: 100,
assistantMessageCount: 1,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
checks: [{ name: "edited", passed: false }],
judgeScore: 10,
judgeSummary: "missed",
error: "failed",
tokenUsage: { prompt: 10, completion: 5, total: 15 },
},
]),
],
});
expect(result.attemptCount).toBe(2);
expect(result.passedAttempts).toBe(1);
expect(result.passRate).toBe(0.5);
expect(result.averageDurationMs).toBe(550);
expect(result.averagePassedDurationMs).toBe(1000);
expect(result.totalTokenUsage).toEqual({
prompt: 110,
completion: 25,
total: 135,
});
expect(result.totalPassedTokenUsage).toEqual({
prompt: 100,
completion: 20,
total: 120,
});
expect(result.averageTokenUsagePerAttempt).toEqual({
prompt: 55,
completion: 12.5,
total: 67.5,
});
expect(result.averageTokenUsagePerPassedAttempt).toEqual({
prompt: 100,
completion: 20,
total: 120,
});
const summary = formatRunSummary(result);
expect(summary).toContain("Average duration (passed): 1000ms");
expect(summary).toContain("Average tokens (passed): 120 total");
expect(summary).toContain("Average duration (all attempts): 550ms");
});
it("reports passed averages as unavailable when no attempt passes", () => {
const result = buildRunResult({
mode: "global",
runs: 1,
runModel: "model-under-test",
judgeModel: "judge-model",
caseResults: [
caseResult([
{
attempt: 1,
passed: false,
durationMs: 100,
assistantMessageCount: 1,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
checks: [{ name: "edited", passed: false }],
judgeScore: 10,
judgeSummary: "missed",
error: "failed",
tokenUsage: { prompt: 10, completion: 5, total: 15 },
},
]),
],
});
expect(result.averagePassedDurationMs).toBeNull();
expect(result.totalPassedTokenUsage).toBeNull();
expect(result.averageTokenUsagePerPassedAttempt).toBeNull();
expect(formatRunSummary(result)).toContain(
"Average duration (passed): n/a",
);
});
it("normalizes passed token averages by passed attempts", () => {
const result = buildRunResult({
mode: "global",
runs: 1,
runModel: "model-under-test",
judgeModel: "judge-model",
caseResults: [
caseResult([
{
attempt: 1,
passed: true,
durationMs: 1000,
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["edit_script"],
skillsInvoked: [],
checks: [{ name: "edited", passed: true }],
judgeScore: 100,
judgeSummary: "ok",
error: null,
tokenUsage: { prompt: 100, completion: 20, total: 120 },
},
{
attempt: 2,
passed: true,
durationMs: 1200,
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["edit_script"],
skillsInvoked: [],
checks: [{ name: "edited", passed: true }],
judgeScore: 100,
judgeSummary: "ok",
error: null,
tokenUsage: null,
},
]),
],
});
expect(result.passedAttempts).toBe(2);
expect(result.totalPassedTokenUsage).toEqual({
prompt: 100,
completion: 20,
total: 120,
});
expect(result.averageTokenUsagePerPassedAttempt).toEqual({
prompt: 50,
completion: 10,
total: 60,
});
});
it("records passed-attempt metrics in history", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "windmill-ai-evals-"));
try {
const historyPath = join(tempDir, "history.jsonl");
const result = buildRunResult({
mode: "global",
runs: 1,
runModel: "model-under-test",
judgeModel: "judge-model",
caseResults: [
caseResult([
{
attempt: 1,
passed: true,
durationMs: 1000,
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["edit_script"],
skillsInvoked: [],
checks: [{ name: "edited", passed: true }],
judgeScore: 100,
judgeSummary: "ok",
error: null,
tokenUsage: { prompt: 100, completion: 20, total: 120 },
},
{
attempt: 2,
passed: false,
durationMs: 100,
assistantMessageCount: 1,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
checks: [{ name: "edited", passed: false }],
judgeScore: 10,
judgeSummary: "missed",
error: "failed",
tokenUsage: { prompt: 10, completion: 5, total: 15 },
},
]),
],
});
await appendHistoryRecord(result, historyPath);
const record = JSON.parse(await readFile(historyPath, "utf8"));
expect(record.averageDurationMs).toBe(550);
expect(record.averagePassedDurationMs).toBe(1000);
expect(record.averageTokenUsagePerAttempt.total).toBe(67.5);
expect(record.averageTokenUsagePerPassedAttempt.total).toBe(120);
expect(record.cases[0].averageDurationMs).toBe(550);
expect(record.cases[0].averagePassedDurationMs).toBe(1000);
expect(record.cases[0].averageTokenUsagePerAttempt.total).toBe(67.5);
expect(record.cases[0].averageTokenUsagePerPassedAttempt.total).toBe(
120,
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
});
+114 -67
View File
@@ -4,12 +4,20 @@ import { execFileSync } from "node:child_process";
import { getAiEvalsRoot, getRepoRoot } from "./cases";
import type {
BenchmarkArtifactFile,
BenchmarkAttemptResult,
BenchmarkCaseResult,
BenchmarkRunResult,
BenchmarkTokenUsage,
EvalMode,
} from "./types";
type AttemptAggregate = {
attemptCount: number;
durationTotal: number;
tokenUsageAttemptCount: number;
tokenUsageTotal: BenchmarkTokenUsage | null;
};
export async function writeRunResult(
result: BenchmarkRunResult,
outputPath?: string,
@@ -77,36 +85,12 @@ export function buildRunResult(input: {
judgeModel: string | null;
caseResults: BenchmarkCaseResult[];
}): BenchmarkRunResult {
const attemptCount = input.caseResults.reduce(
(sum, entry) => sum + entry.attempts.length,
0,
);
const passedAttempts = input.caseResults.reduce(
(sum, entry) =>
sum + entry.attempts.filter((attempt) => attempt.passed).length,
0,
);
const durationTotal = input.caseResults.reduce(
(sum, entry) =>
sum +
entry.attempts.reduce((inner, attempt) => inner + attempt.durationMs, 0),
0,
);
const tokenUsageTotal = input.caseResults.reduce<BenchmarkTokenUsage | null>(
(sum, entry) => {
for (const attempt of entry.attempts) {
if (!attempt.tokenUsage) {
continue;
}
sum ??= { prompt: 0, completion: 0, total: 0 };
sum.prompt += attempt.tokenUsage.prompt;
sum.completion += attempt.tokenUsage.completion;
sum.total += attempt.tokenUsage.total;
}
return sum;
},
null,
);
const attempts = input.caseResults.flatMap((entry) => entry.attempts);
const passedAttemptResults = attempts.filter((attempt) => attempt.passed);
const attemptAggregate = aggregateAttempts(attempts);
const passedAttemptAggregate = aggregateAttempts(passedAttemptResults);
const attemptCount = attemptAggregate.attemptCount;
const passedAttempts = passedAttemptAggregate.attemptCount;
return {
version: 1,
@@ -120,16 +104,19 @@ export function buildRunResult(input: {
attemptCount,
passedAttempts,
passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount,
averageDurationMs: attemptCount === 0 ? 0 : durationTotal / attemptCount,
totalTokenUsage: tokenUsageTotal,
averageDurationMs:
attemptCount === 0 ? 0 : attemptAggregate.durationTotal / attemptCount,
averagePassedDurationMs: averageDuration(passedAttemptAggregate),
totalTokenUsage: attemptAggregate.tokenUsageTotal,
totalPassedTokenUsage: passedAttemptAggregate.tokenUsageTotal,
averageTokenUsagePerAttempt:
attemptCount === 0 || !tokenUsageTotal
attemptCount === 0
? null
: {
prompt: tokenUsageTotal.prompt / attemptCount,
completion: tokenUsageTotal.completion / attemptCount,
total: tokenUsageTotal.total / attemptCount,
},
: averageTokenUsage(attemptAggregate, attemptCount),
averageTokenUsagePerPassedAttempt: averageTokenUsage(
passedAttemptAggregate,
passedAttempts,
),
cases: input.caseResults,
};
}
@@ -138,9 +125,25 @@ export function formatRunSummary(result: BenchmarkRunResult): string {
const lines = [
`${result.mode} benchmark complete`,
`Pass rate: ${formatPercent(result.passRate)} (${result.passedAttempts}/${result.attemptCount})`,
`Average duration: ${Math.round(result.averageDurationMs)}ms`,
`Average duration (passed): ${formatNullableDuration(result.averagePassedDurationMs ?? null)}`,
];
if (result.averageTokenUsagePerPassedAttempt) {
lines.push(
`Average tokens (passed): ${formatTokenUsage(result.averageTokenUsagePerPassedAttempt)}`,
);
}
if (result.passedAttempts < result.attemptCount) {
lines.push(
`Average duration (all attempts): ${Math.round(result.averageDurationMs)}ms`,
);
if (result.averageTokenUsagePerAttempt) {
lines.push(
`Average tokens (all attempts): ${formatTokenUsage(result.averageTokenUsagePerAttempt)}`,
);
}
}
const failures = collectFailures(result);
if (failures.length > 0) {
lines.push("Failures:");
@@ -172,6 +175,60 @@ function collectFailures(result: BenchmarkRunResult): string[] {
return failures;
}
function aggregateAttempts(attempts: BenchmarkAttemptResult[]): AttemptAggregate {
const aggregate: AttemptAggregate = {
attemptCount: attempts.length,
durationTotal: 0,
tokenUsageAttemptCount: 0,
tokenUsageTotal: null,
};
for (const attempt of attempts) {
aggregate.durationTotal += attempt.durationMs;
if (!attempt.tokenUsage) {
continue;
}
aggregate.tokenUsageAttemptCount += 1;
aggregate.tokenUsageTotal ??= { prompt: 0, completion: 0, total: 0 };
aggregate.tokenUsageTotal.prompt += attempt.tokenUsage.prompt;
aggregate.tokenUsageTotal.completion += attempt.tokenUsage.completion;
aggregate.tokenUsageTotal.total += attempt.tokenUsage.total;
}
return aggregate;
}
function averageDuration(aggregate: AttemptAggregate): number | null {
return aggregate.attemptCount === 0
? null
: aggregate.durationTotal / aggregate.attemptCount;
}
function averageTokenUsage(
aggregate: AttemptAggregate,
denominator: number,
): BenchmarkTokenUsage | null {
if (denominator === 0 || !aggregate.tokenUsageTotal) {
return null;
}
return {
prompt: aggregate.tokenUsageTotal.prompt / denominator,
completion: aggregate.tokenUsageTotal.completion / denominator,
total: aggregate.tokenUsageTotal.total / denominator,
};
}
function formatNullableDuration(value: number | null): string {
return value === null ? "n/a" : `${Math.round(value)}ms`;
}
function formatTokenUsage(value: BenchmarkTokenUsage): string {
const total = Math.round(value.total);
const prompt = Math.round(value.prompt);
const completion = Math.round(value.completion);
return `${total} total (${prompt} prompt, ${completion} completion)`;
}
function defaultFileName(mode: EvalMode): string {
return `${new Date().toISOString().replaceAll(":", "-")}__${mode}.json`;
}
@@ -252,12 +309,15 @@ function toHistoryRecord(result: BenchmarkRunResult) {
passedAttempts: result.passedAttempts,
passRate: result.passRate,
averageDurationMs: result.averageDurationMs,
averagePassedDurationMs: result.averagePassedDurationMs ?? null,
averageJudgeScore:
judgeScores.length === 0
? null
: judgeScores.reduce((sum, score) => sum + score, 0) /
judgeScores.length,
averageTokenUsagePerAttempt: result.averageTokenUsagePerAttempt ?? null,
averageTokenUsagePerPassedAttempt:
result.averageTokenUsagePerPassedAttempt ?? null,
failedCaseIds: Array.from(
new Set(
result.cases
@@ -268,31 +328,15 @@ function toHistoryRecord(result: BenchmarkRunResult) {
),
),
cases: result.cases.map((caseResult) => {
const attemptCount = caseResult.attempts.length;
const passedAttempts = caseResult.attempts.filter(
(attempt) => attempt.passed,
).length;
const totalDurationMs = caseResult.attempts.reduce(
(sum, attempt) => sum + attempt.durationMs,
0,
const attemptAggregate = aggregateAttempts(caseResult.attempts);
const passedAttemptAggregate = aggregateAttempts(
caseResult.attempts.filter((attempt) => attempt.passed),
);
const attemptCount = attemptAggregate.attemptCount;
const passedAttempts = passedAttemptAggregate.attemptCount;
const judgeScores = caseResult.attempts.flatMap((attempt) =>
typeof attempt.judgeScore === "number" ? [attempt.judgeScore] : [],
);
const totalTokenUsage =
caseResult.attempts.reduce<BenchmarkTokenUsage | null>(
(sum, attempt) => {
if (!attempt.tokenUsage) {
return sum;
}
sum ??= { prompt: 0, completion: 0, total: 0 };
sum.prompt += attempt.tokenUsage.prompt;
sum.completion += attempt.tokenUsage.completion;
sum.total += attempt.tokenUsage.total;
return sum;
},
null,
);
return {
id: caseResult.id,
@@ -300,20 +344,23 @@ function toHistoryRecord(result: BenchmarkRunResult) {
passedAttempts,
passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount,
averageDurationMs:
attemptCount === 0 ? 0 : totalDurationMs / attemptCount,
attemptCount === 0
? 0
: attemptAggregate.durationTotal / attemptCount,
averagePassedDurationMs: averageDuration(passedAttemptAggregate),
averageJudgeScore:
judgeScores.length === 0
? null
: judgeScores.reduce((sum, score) => sum + score, 0) /
judgeScores.length,
averageTokenUsagePerAttempt:
attemptCount === 0 || !totalTokenUsage
attemptCount === 0
? null
: {
prompt: totalTokenUsage.prompt / attemptCount,
completion: totalTokenUsage.completion / attemptCount,
total: totalTokenUsage.total / attemptCount,
},
: averageTokenUsage(attemptAggregate, attemptCount),
averageTokenUsagePerPassedAttempt: averageTokenUsage(
passedAttemptAggregate,
passedAttempts,
),
};
}),
};
+15 -1
View File
@@ -110,7 +110,9 @@ export interface AppValidationSpec {
export interface GlobalDraftRequirement {
type: string;
path: string;
path?: string;
pathIncludes?: string[];
pathStartsWith?: string;
triggerKind?: string;
language?: string;
summaryIncludes?: string[];
@@ -153,6 +155,15 @@ export interface ToolCallArgumentRule {
field: string;
stringStartsWithAnyOf?: string[];
stringMustNotStartWithAnyOf?: string[];
/**
* Case-insensitive "contains", existential over calls: at least one recorded
* call to `tool` must have `field` containing one of these substrings. Other
* calls to the same tool may do anything. Use instead of `stringStartsWithAnyOf`
* (which is universal over calls) when the meaningful token can appear anywhere
* in the value and the model may make additional, unrelated calls to the same
* tool — e.g. SQL where a mutation is mixed with verification SELECTs.
*/
stringIncludesAnyOf?: string[];
}
export interface ToolValidationSpec {
@@ -324,8 +335,11 @@ export interface BenchmarkRunResult {
passedAttempts: number;
passRate: number;
averageDurationMs: number;
averagePassedDurationMs?: number | null;
totalTokenUsage?: BenchmarkTokenUsage | null;
totalPassedTokenUsage?: BenchmarkTokenUsage | null;
averageTokenUsagePerAttempt?: BenchmarkTokenUsage | null;
averageTokenUsagePerPassedAttempt?: BenchmarkTokenUsage | null;
artifactsPath?: string | null;
cases: BenchmarkCaseResult[];
}
+168
View File
@@ -140,6 +140,111 @@ describe("validateToolExpectations", () => {
details: "tools used: write_script, deploy_workspace_item",
});
});
it("accepts a stringIncludesAnyOf substring regardless of case or position", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["exec_datatable_sql"],
toolCallDetails: [
{
name: "exec_datatable_sql",
arguments: {
sql: "WITH recent AS (SELECT * FROM orders) SELECT count(*) FROM recent",
},
},
],
skillsInvoked: [],
},
toolExpect: {
requiredToolsUsed: ["exec_datatable_sql"],
toolCallArgs: [
{
tool: "exec_datatable_sql",
field: "sql",
stringIncludesAnyOf: ["select"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("accepts stringIncludesAnyOf when only one of several calls matches", () => {
// Existential: a mutation mixed with verification SELECTs still passes.
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 2,
toolsUsed: ["exec_datatable_sql"],
toolCallDetails: [
{
name: "exec_datatable_sql",
arguments: { sql: "UPDATE orders SET status = 'shipped' WHERE id = 2" },
},
{
name: "exec_datatable_sql",
arguments: { sql: "SELECT * FROM orders WHERE id = 2" },
},
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [
{
tool: "exec_datatable_sql",
field: "sql",
stringIncludesAnyOf: ["insert into", "update"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("rejects stringIncludesAnyOf when no call matches any substring", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["exec_datatable_sql"],
toolCallDetails: [
{
name: "exec_datatable_sql",
arguments: {
sql: "DROP TABLE orders",
},
},
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [
{
tool: "exec_datatable_sql",
field: "sql",
stringIncludesAnyOf: ["insert into", "update"],
},
],
},
});
expect(checks).toContainEqual({
name: "exec_datatable_sql.sql includes a required substring",
passed: false,
details:
'accepted substrings: insert into, update; values: "DROP TABLE orders"',
});
});
});
describe("validateGlobalState", () => {
@@ -195,6 +300,69 @@ describe("validateGlobalState", () => {
});
});
it("accepts a required script draft without an exact path", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/team_tools/friendly_greeting",
language: "bun",
summary: "Friendly greeting helper",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
validate: {
draftCountExactly: 1,
requiredDrafts: [
{
type: "script",
pathIncludes: ["greeting"],
language: "bun",
summaryIncludes: ["Friendly"],
valueIncludes: ["Hello"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("reports flexible global draft path filters when no draft matches", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/team_tools/friendly_greeting",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
validate: {
requiredDrafts: [
{
type: "script",
pathIncludes: ["invoice"],
},
],
},
});
expect(checks).toContainEqual({
name: "global includes script draft (path includes invoice)",
passed: false,
details: "drafts: script:f/team_tools/friendly_greeting",
});
});
it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => {
const checks = validateGlobalState({
actual: {
+119 -15
View File
@@ -222,6 +222,25 @@ export function validateToolExpectations(input: {
)
);
}
if (rule.stringIncludesAnyOf && rule.stringIncludesAnyOf.length > 0) {
// Existential: at least one call must contain one of the substrings.
// Other calls to the same tool may do anything — this suits SQL, where a
// model mixes the requested statement (e.g. an UPDATE) with verification
// SELECTs that would otherwise fail an "all calls" check.
const needles = rule.stringIncludesAnyOf.map((needle) => needle.toLowerCase());
const hasMatch = values.some(
(value) =>
typeof value === "string" && needles.some((needle) => value.toLowerCase().includes(needle))
);
checks.push(
check(
`${rule.tool}.${rule.field} includes a required substring`,
hasMatch,
`accepted substrings: ${rule.stringIncludesAnyOf.join(", ")}; values: ${summarizeToolValues(values)}`
)
);
}
}
return checks;
@@ -315,10 +334,11 @@ export function validateGlobalState(input: {
}
for (const required of validate.requiredDrafts ?? []) {
const draft = findGlobalDraft(drafts, required.type, required.path, required.triggerKind);
const requirementLabel = formatGlobalDraftRequirement(required);
const draft = findGlobalDraft(drafts, required);
checks.push(
check(
`global includes ${required.type} draft ${required.path}`,
`global includes ${requirementLabel}`,
Boolean(draft),
summarizeGlobalDrafts(drafts)
)
@@ -330,7 +350,7 @@ export function validateGlobalState(input: {
if (required.language !== undefined) {
checks.push(
check(
`${required.type} draft ${required.path} uses ${required.language}`,
`${requirementLabel} uses ${required.language}`,
draft.language === required.language,
`language=${draft.language ?? "(none)"}`
)
@@ -340,7 +360,7 @@ export function validateGlobalState(input: {
for (const snippet of required.summaryIncludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} summary includes '${snippet}'`,
`${requirementLabel} summary includes '${snippet}'`,
normalizeText(draft.summary ?? "").includes(normalizeText(snippet)),
`summary=${draft.summary ?? ""}`
)
@@ -351,7 +371,7 @@ export function validateGlobalState(input: {
for (const snippet of required.valueIncludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} value includes '${snippet}'`,
`${requirementLabel} value includes '${snippet}'`,
normalizeText(valueText).includes(normalizeText(snippet)),
truncateForDetails(valueText)
)
@@ -361,7 +381,7 @@ export function validateGlobalState(input: {
for (const snippet of required.valueExcludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} value excludes '${snippet}'`,
`${requirementLabel} value excludes '${snippet}'`,
!normalizeText(valueText).includes(normalizeText(snippet)),
truncateForDetails(valueText)
)
@@ -373,7 +393,7 @@ export function validateGlobalState(input: {
checks.push(
check(
`global does not include ${forbidden.type} draft ${forbidden.path}`,
!findGlobalDraft(drafts, forbidden.type, forbidden.path, forbidden.triggerKind),
!findGlobalDraft(drafts, forbidden),
summarizeGlobalDrafts(drafts)
)
);
@@ -615,16 +635,100 @@ function summarizeProblems(problems: string[], limit = 5): string | undefined {
function findGlobalDraft(
drafts: GlobalDraft[],
type: string,
path: string,
triggerKind?: string
requirement: {
type: string;
path?: string;
pathIncludes?: string[];
pathStartsWith?: string;
triggerKind?: string;
summaryIncludes?: string[];
valueIncludes?: string[];
valueExcludes?: string[];
}
): GlobalDraft | undefined {
return drafts.find(
(draft) =>
draft.type === type &&
draft.path === path &&
(triggerKind === undefined || draft.triggerKind === triggerKind)
const candidates = drafts.filter((draft) =>
globalDraftMatchesLocator(draft, requirement)
);
return (
candidates.find((draft) => globalDraftMatchesContent(draft, requirement)) ??
candidates[0]
);
}
function globalDraftMatchesLocator(
draft: GlobalDraft,
requirement: {
type: string;
path?: string;
pathIncludes?: string[];
pathStartsWith?: string;
triggerKind?: string;
}
): boolean {
return (
draft.type === requirement.type &&
(requirement.path === undefined || draft.path === requirement.path) &&
(requirement.pathStartsWith === undefined ||
draft.path.startsWith(requirement.pathStartsWith)) &&
(requirement.pathIncludes ?? []).every((snippet) =>
normalizeText(draft.path).includes(normalizeText(snippet))
) &&
(requirement.triggerKind === undefined ||
draft.triggerKind === requirement.triggerKind)
);
}
function globalDraftMatchesContent(
draft: GlobalDraft,
requirement: {
summaryIncludes?: string[];
valueIncludes?: string[];
valueExcludes?: string[];
}
): boolean {
const summary = normalizeText(draft.summary ?? "");
const value = normalizeText(stringifyGlobalDraftValue(draft.value));
return (
(requirement.summaryIncludes ?? []).every((snippet) =>
summary.includes(normalizeText(snippet))
) &&
(requirement.valueIncludes ?? []).every((snippet) =>
value.includes(normalizeText(snippet))
) &&
(requirement.valueExcludes ?? []).every(
(snippet) => !value.includes(normalizeText(snippet))
)
);
}
function formatGlobalDraftRequirement(
requirement: {
type: string;
path?: string;
pathIncludes?: string[];
pathStartsWith?: string;
triggerKind?: string;
}
): string {
const typeLabel =
requirement.triggerKind === undefined
? requirement.type
: `${requirement.triggerKind} ${requirement.type}`;
if (requirement.path !== undefined) {
return `${typeLabel} draft ${requirement.path}`;
}
const filters = [
...(requirement.pathStartsWith === undefined
? []
: [`path starts with ${requirement.pathStartsWith}`]),
...(requirement.pathIncludes ?? []).map(
(snippet) => `path includes ${snippet}`
),
];
return filters.length === 0
? `${typeLabel} draft`
: `${typeLabel} draft (${filters.join(", ")})`;
}
function summarizeGlobalDrafts(drafts: GlobalDraft[]): string {
@@ -0,0 +1,66 @@
{
"workspace": {
"scripts": [
{
"path": "f/evals/global/format_greeting",
"summary": "Format a deployed greeting",
"description": "Returns a plain greeting for a provided name.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
},
"content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n"
},
{
"path": "f/evals/global/format_greeting_archive",
"summary": "Archived greeting formatter",
"description": "Older greeting formatter kept for reference.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
},
"content": "export async function main(name: string) {\n return `Hi, ${name}`\n}\n"
}
]
},
"liveEditorDrafts": [
{
"type": "script",
"storagePath": "f/evals/global/current_greeting",
"effectivePath": "f/evals/global/current_greeting",
"value": {
"path": "f/evals/global/current_greeting",
"summary": "Open greeting formatter",
"description": "Formats a greeting in the live editor.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
},
"content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n",
"is_template": false,
"kind": "script"
}
}
]
}
@@ -0,0 +1,118 @@
{
"workspace": {
"flows": [
{
"path": "f/evals/global/process_invoice",
"summary": "Deployed invoice processor",
"description": "Calculates invoice totals from a subtotal.",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"subtotal": {
"type": "number"
}
},
"required": ["subtotal"]
},
"value": {
"modules": [
{
"id": "calculate_total",
"summary": "Calculate total from subtotal",
"value": {
"type": "rawscript",
"language": "bun",
"content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n",
"input_transforms": {
"subtotal": {
"type": "javascript",
"expr": "flow_input.subtotal"
}
}
}
}
]
}
},
{
"path": "f/evals/global/process_refund",
"summary": "Refund processor",
"description": "Calculates refund totals.",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"subtotal": {
"type": "number"
}
},
"required": ["subtotal"]
},
"value": {
"modules": [
{
"id": "calculate_total",
"summary": "Calculate refund total",
"value": {
"type": "rawscript",
"language": "bun",
"content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n",
"input_transforms": {
"subtotal": {
"type": "javascript",
"expr": "flow_input.subtotal"
}
}
}
}
]
}
}
]
},
"liveEditorDrafts": [
{
"type": "flow",
"storagePath": "f/evals/global/current_invoice_flow",
"effectivePath": "f/evals/global/current_invoice_flow",
"value": {
"path": "f/evals/global/current_invoice_flow",
"summary": "Open invoice processor",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"subtotal": {
"type": "number"
}
},
"required": ["subtotal"]
},
"value": {
"modules": [
{
"id": "calculate_total",
"summary": "Calculate total from subtotal",
"value": {
"type": "rawscript",
"language": "bun",
"content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n",
"input_transforms": {
"subtotal": {
"type": "javascript",
"expr": "flow_input.subtotal"
}
}
}
}
]
},
"edited_by": "",
"edited_at": "",
"archived": false,
"extra_perms": {}
}
}
]
}
@@ -0,0 +1,39 @@
{
"workspace": {
"datatables": [
{
"datatable_name": "main",
"schemas": {
"public": {
"orders": {
"columns": {
"id": "int4",
"customer_id": "int4",
"total": "numeric",
"status": "text",
"created_at": "timestamptz"
},
"rows": [
{ "id": 1, "customer_id": 1, "total": 42.5, "status": "shipped", "created_at": "2026-05-01T10:00:00Z" },
{ "id": 2, "customer_id": 2, "total": 19.99, "status": "pending", "created_at": "2026-05-02T11:30:00Z" },
{ "id": 3, "customer_id": 1, "total": 88, "status": "shipped", "created_at": "2026-05-03T09:15:00Z" }
]
},
"customers": {
"columns": {
"id": "int4",
"name": "text",
"email": "text",
"tier": "text"
},
"rows": [
{ "id": 1, "name": "Alice", "email": "alice@example.com", "tier": "gold" },
{ "id": 2, "name": "Bob", "email": "bob@example.com", "tier": "silver" }
]
}
}
}
}
]
}
}
@@ -0,0 +1,30 @@
{
"workspace": {
"jobs": [
{
"id": "01920000-0000-7000-8000-0000000000f1",
"scriptPath": "f/etl/sync_customers",
"jobKind": "script",
"createdBy": "alice",
"success": false,
"logs": "Starting customer sync...\nFetched 0 records\nERROR: connection refused to https://api.upstream.example.com\n at fetchCustomers (sync_customers.ts:42)\nJob failed with exit code 1"
},
{
"id": "01920000-0000-7000-8000-0000000000f2",
"scriptPath": "f/reports/daily_digest",
"jobKind": "script",
"createdBy": "bob",
"success": true,
"logs": "Generating daily digest...\nDigest emailed to 12 recipients\nDone in 1.2s"
},
{
"id": "01920000-0000-7000-8000-0000000000f3",
"scriptPath": "f/billing/charge_invoices",
"jobKind": "flow",
"createdBy": "alice",
"success": true,
"logs": "Processing invoices...\nCharged 8 invoices totalling $1,240.00\nDone"
}
]
}
}
@@ -0,0 +1,40 @@
{
"workspace": {
"flows": [
{
"path": "f/evals/global/process_invoice",
"summary": "Process an invoice subtotal",
"description": "Calculates invoice totals from a subtotal.",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"subtotal": {
"type": "number"
}
},
"required": ["subtotal"]
},
"value": {
"modules": [
{
"id": "calculate_total",
"summary": "Calculate total from subtotal",
"value": {
"type": "rawscript",
"language": "bun",
"content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n",
"input_transforms": {
"subtotal": {
"type": "javascript",
"expr": "flow_input.subtotal"
}
}
}
}
]
}
}
]
}
}
@@ -0,0 +1,23 @@
{
"workspace": {
"scripts": [
{
"path": "f/evals/global/send_report_digest",
"summary": "Build and send the eval report digest",
"description": "Returns a dry-run summary for eval report digest notifications.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"dry_run": {
"type": "boolean"
}
},
"required": ["dry_run"]
},
"content": "export async function main(dry_run: boolean) {\n return { dry_run, sent: !dry_run, message: dry_run ? 'Preview digest' : 'Digest sent' }\n}\n"
}
]
}
}
+8 -1
View File
@@ -5,12 +5,14 @@ const ORIGINAL_ENV = {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
};
afterEach(() => {
process.env.ANTHROPIC_API_KEY = ORIGINAL_ENV.ANTHROPIC_API_KEY;
process.env.OPENAI_API_KEY = ORIGINAL_ENV.OPENAI_API_KEY;
process.env.GEMINI_API_KEY = ORIGINAL_ENV.GEMINI_API_KEY;
process.env.DEEPSEEK_API_KEY = ORIGINAL_ENV.DEEPSEEK_API_KEY;
});
describe("getFrontendApiKey", () => {
@@ -19,10 +21,15 @@ describe("getFrontendApiKey", () => {
expect(getFrontendApiKey("googleai")).toBe("gemini-test-key");
});
it("reads the DeepSeek API key for deepseek models", () => {
process.env.DEEPSEEK_API_KEY = "deepseek-test-key";
expect(getFrontendApiKey("deepseek")).toBe("deepseek-test-key");
});
it("throws a provider-specific error when the key is missing", () => {
delete process.env.GEMINI_API_KEY;
expect(() => getFrontendApiKey("googleai")).toThrow(
"GEMINI_API_KEY is required for frontend evals"
"GEMINI_API_KEY is required for frontend evals",
);
});
});
+6 -2
View File
@@ -1,12 +1,16 @@
import type { FrontendEvalModelConfig } from "../core/models";
export function getFrontendApiKey(provider: FrontendEvalModelConfig["provider"]): string {
export function getFrontendApiKey(
provider: FrontendEvalModelConfig["provider"],
): string {
const envName =
provider === "anthropic"
? "ANTHROPIC_API_KEY"
: provider === "googleai"
? "GEMINI_API_KEY"
: "OPENAI_API_KEY";
: provider === "deepseek"
? "DEEPSEEK_API_KEY"
: "OPENAI_API_KEY";
const apiKey = process.env[envName];
if (!apiKey) {
throw new Error(`${envName} is required for frontend evals`);
+7 -1
View File
@@ -1,5 +1,8 @@
import { readFile } from "node:fs/promises";
import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner";
import {
runGlobalEval,
type GlobalLiveEditorDraftFixture,
} from "../adapters/frontend/core/global/globalEvalRunner";
import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend";
import type { FrontendEvalModelConfig } from "../core/models";
import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types";
@@ -9,6 +12,7 @@ import { getFrontendApiKey } from "./frontendCommon";
export interface GlobalInitialFixture {
workspace?: BenchmarkWorkspaceRunnables;
liveEditorDrafts?: GlobalLiveEditorDraftFixture[];
}
export function createGlobalModeRunner(
@@ -31,6 +35,7 @@ export function createGlobalModeRunner(
getFrontendApiKey(modelConfig.provider),
{
workspaceFixtures: initial?.workspace,
liveEditorDrafts: initial?.liveEditorDrafts,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
@@ -73,6 +78,7 @@ async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixt
const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture;
return {
workspace: parsed.workspace ?? {},
liveEditorDrafts: parsed.liveEditorDrafts ?? [],
};
}
@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "devops",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "verified",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "first_time_user",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "role_source",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
null,
false,
false,
false,
true,
true,
true,
null,
false,
false,
false,
null
]
},
"hash": "0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of_email,\n flow.edited_by,\n flow.labels\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ",
"query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n flow_version.value->>'failure_module' IS NOT NULL as has_failure_module,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of_email,\n flow.edited_by,\n flow.labels\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ",
"describe": {
"columns": [
{
@@ -20,31 +20,36 @@
},
{
"ordinal": 3,
"name": "chat_input_enabled",
"name": "has_failure_module",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "chat_input_enabled",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 5,
"ordinal": 6,
"name": "dedicated_worker",
"type_info": "Bool"
},
{
"ordinal": 6,
"ordinal": 7,
"name": "on_behalf_of_email",
"type_info": "Text"
},
{
"ordinal": 7,
"ordinal": 8,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 8,
"ordinal": 9,
"name": "labels",
"type_info": "TextArray"
}
@@ -61,6 +66,7 @@
null,
null,
null,
null,
true,
true,
true,
@@ -68,5 +74,5 @@
true
]
},
"hash": "6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777"
"hash": "04409657066c624308954958d9dd451452efc25e57769fb94b771d4879150835"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.path FROM flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE flow_version.id = $1 AND flow_version.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0476ae2245aa678a50c5fd04cdee32cc151e29b177cc85caa088430b16336373"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM flow WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "0a568f630e069118fe302099a709e89cc4a702158899f7fdc66d0922e8fb9b29"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT operator, is_admin FROM usr WHERE email = $1 AND is_service_account IS true LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "is_admin",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "0cca68f11329cd41ab9372297b715af008ea7db408f49cf525656d6224092429"
}
@@ -0,0 +1,42 @@
{
"db_name": "PostgreSQL",
"query": "WITH bounds AS (\n SELECT ($2::bigint % 4294967296)::text::xid AS prev_xid,\n ($3::bigint % 4294967296)::text::xid AS cur_xid,\n $1::timestamptz AS ts_floor\n ),\n batch AS (\n SELECT workspace_id, id, timestamp, username, operation,\n action_kind::text AS action_kind, resource, parameters, email, span\n FROM audit_partitioned, bounds b\n WHERE timestamp >= b.ts_floor\n AND age(xmin) > age(b.cur_xid)\n AND age(xmin) <= age(b.prev_xid)\n ORDER BY id\n )\n SELECT to_char(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS \"day!\",\n string_agg(row_to_json(batch)::text, E'\\n' ORDER BY id) AS \"ndjson!\",\n max(id) AS \"max_id!\",\n max(timestamp) AS \"max_ts!\"\n FROM batch\n GROUP BY 1\n ORDER BY 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "day!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "ndjson!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "max_id!",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "max_ts!",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Timestamptz",
"Int8",
"Int8"
]
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "0f5ec10de91deac2d40b1e8a6dad0e9341f57cd060e7f228b40634469269c407"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url,\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "installation_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "account_id",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "github_base_url",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "provisioned_by_admin!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701"
}
@@ -0,0 +1,95 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, true as operator_only, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC, \"email!\"\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "verified!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "super_admin!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "devops!",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "first_time_user!",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "role_source!",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled!",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37"
}
@@ -0,0 +1,23 @@
{
"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 id AS \"id!\" FROM chain",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319"
}
@@ -0,0 +1,100 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "devops",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "verified",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "is_workspace_admin",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "first_time_user",
"type_info": "Bool"
},
{
"ordinal": 11,
"name": "role_source",
"type_info": "Varchar"
},
{
"ordinal": 12,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 13,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
null,
false,
false,
false,
true,
true,
true,
null,
null,
false,
false,
false,
null
]
},
"hash": "1d0341bd8de94ab8d34a4bb1bb2005305fb3b29751ec987bece183444e89e7d1"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Int4",
"Bool",
"Timestamptz",
"TextArray",
"Varchar"
]
},
"nullable": []
},
"hash": "295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)\n VALUES ('wm-fork-stale-super', 'f/folder2/myscript', 333333, 'echo 1', '', '', 'bash', 'test-user-2', NOW(), false, false, false, false, $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "2e35598cb9695b726ee1d2cd5c8364503371a5272d88e970b95760555ab33ce2"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"is_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n AND (elem->>'installation_id')::bigint = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_admin!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)\n VALUES ('wm-fork-visibility-test', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "31445efb75a7b706f4404c411a4ef6a9ed6d29a02bb7fd08f2ceb0551ecac640"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)\n VALUES ('test-workspace', 'folder1', 'folder1', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "31e486e3377e79bfab4e391d6789d081edf45fef34f630372815ec323544acee"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM workspace_settings WHERE workspace_id = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17\n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed, -- 27,\n labels -- 44\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27, $44)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email)\n values ($1, $32, $33, $34, $35, $36, $37, $2, $41)\n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42, $43)",
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17\n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed, -- 27,\n labels -- 44\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27,\n -- $44 (payload labels) merged with the labels of the runnable's folder, if any\n -- ($45/$46 are runnable_path/workspace_id again, kept separate to avoid parameter type conflicts)\n (SELECT CASE WHEN fl.labels IS NULL THEN $44\n ELSE (SELECT array_agg(DISTINCT lbl) FROM unnest(COALESCE($44, ARRAY[]::TEXT[]) || fl.labels) lbl)\n END\n FROM folder_labels($46, $45) AS fl(labels)))\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email)\n values ($1, $32, $33, $34, $35, $36, $37, $2, $41)\n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42, $43)",
"describe": {
"columns": [],
"parameters": {
@@ -136,10 +136,12 @@
"Varchar",
"Bool",
"Int8",
"TextArray"
"TextArray",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "756f82b72af07fd690f37b2e16ed2d390604f4fc4cb330842a88d5764cbcf0c6"
"hash": "3c84781704b84b8a927ecce5a3fcb3adcf0175d0a71013f2497397c1c8ccc619"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT policy->>'execution_mode' = 'anonymous' FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "3e82929b365a6aa7ccc39fc5615c4110e1d740e0cb0509b1c58bc6636d83cafd"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) AS \"count!\" FROM workspace_diff\n WHERE source_workspace_id = 'test-workspace'\n AND fork_workspace_id = 'wm-fork-rename-test'\n AND kind = 'script'\n AND path = 'f/folder2/myscript'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "3fac8694f59803a42b635ce7dd1e60a7a4f53c3b7ed6592f70ef4fed97b2bc6d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as FROM folder WHERE name = $1 AND workspace_id = $2",
"query": "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as, labels FROM folder WHERE name = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
@@ -47,6 +47,11 @@
"ordinal": 8,
"name": "default_permissioned_as",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "labels",
"type_info": "TextArray"
}
],
"parameters": {
@@ -64,8 +69,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "269197b692a1e451a31f14701c8ca324c8e40d2923dfa07b8e4b3934afc16fed"
"hash": "42600bdeb6b86ac306ab276cb49241508776f5f998821e04bc92df917176cea6"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT has_changes FROM workspace_diff\n WHERE path = 'f/shared/renamed_away' AND kind = 'script' AND source_workspace_id = 'test-workspace'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_changes",
"type_info": "Bool"
}
],
"parameters": {
"Left": []
},
"nullable": [
true
]
},
"hash": "4c81384b579bad74b64c72ca053839f316fe5412f99f3d3bbbdc0c65f55ab794"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as)\n SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as\n FROM folder\n WHERE workspace_id = $1",
"query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels)\n SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels\n FROM folder\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "ae949d9470fe6687b312fbd9d617d47813a035eb948f0312a8168bb1365607fb"
"hash": "4ca364e493dd51d5f83489dc4674eeadcf092b37a20d03d52f2160ca21f79fb0"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft\n (workspace_id, path, value, typ)\n VALUES ($1, $2, $3::text::json, $4)\n ON CONFLICT (workspace_id, path, typ)\n DO UPDATE SET value = EXCLUDED.value, created_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
{
"Custom": {
"name": "draft_type",
"kind": {
"Enum": [
"script",
"flow",
"app"
]
}
}
}
]
},
"nullable": []
},
"hash": "5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2",
"query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\",\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2",
"describe": {
"columns": [
{
@@ -50,36 +50,41 @@
},
{
"ordinal": 9,
"name": "inherited_labels?",
"type_info": "TextArray"
},
{
"ordinal": 10,
"name": "is_expired",
"type_info": "Bool"
},
{
"ordinal": 10,
"ordinal": 11,
"name": "is_refreshed",
"type_info": "Bool"
},
{
"ordinal": 11,
"ordinal": 12,
"name": "refresh_error",
"type_info": "Text"
},
{
"ordinal": 12,
"ordinal": 13,
"name": "is_linked",
"type_info": "Bool"
},
{
"ordinal": 13,
"ordinal": 14,
"name": "is_oauth?",
"type_info": "Bool"
},
{
"ordinal": 14,
"ordinal": 15,
"name": "account",
"type_info": "Int4"
},
{
"ordinal": 15,
"ordinal": 16,
"name": "ws_specific",
"type_info": "Bool"
}
@@ -102,6 +107,7 @@
true,
null,
null,
null,
true,
null,
false,
@@ -109,5 +115,5 @@
null
]
},
"hash": "9254e2a0e1be830fa4cab660c69a659e2eb3f3d912e1c062bb8000a2e0a653e8"
"hash": "52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f"
}
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4) LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "5347ec9ab6de69a99e8823d2199757b244b280e1262dcbccd2fc5189c7b3d25b"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE variable SET labels = $1, edited_at = now(), edited_by = $4 WHERE path = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"Text",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder (name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as) SELECT name, $1, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as FROM folder WHERE workspace_id = $2",
"query": "INSERT INTO folder (name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels) SELECT name, $1, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels FROM folder WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "86c196327af8705a33ab9a707b4a87a441f800c53885b30f9a9578217e87f2b7"
"hash": "5b4214deb7677ae16df0bea7c6553461dee4bcc8d7180a9a517d5809c42e11d7"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3",
"query": "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as, labels FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
@@ -47,6 +47,11 @@
"ordinal": 8,
"name": "default_permissioned_as",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "labels",
"type_info": "TextArray"
}
],
"parameters": {
@@ -65,8 +70,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "b87d104a97026929983f27a7607490d08b65637a02fb7821571978812144c400"
"hash": "636dd6906a20b608eea34e04a81bddb79398f57a34d180d08aa00aaaaa1050c8"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES\n ('wm-fork-visibility-test', 'test2@windmill.dev', 'test-user-2', false, 'User')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "652637b534f7d7b4c429a201247821e9f568b1976ea462a09e779e9a4c490197"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH capped AS (\n SELECT timestamp, operation, resource, parameters\n FROM audit_partitioned\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.capped'\n UNION ALL\n SELECT timestamp, operation, resource, parameters\n FROM audit\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.capped'\n ORDER BY timestamp DESC\n LIMIT 200\n ), uncapped AS (\n SELECT timestamp, operation, resource, parameters\n FROM audit_partitioned\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.uncapped'\n UNION ALL\n SELECT timestamp, operation, resource, parameters\n FROM audit\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.uncapped'\n ORDER BY timestamp DESC\n LIMIT 200\n )\n SELECT timestamp AS \"timestamp!\",\n operation::text AS \"operation!\",\n resource AS workspace_id,\n parameters\n FROM (SELECT * FROM capped UNION ALL SELECT * FROM uncapped) e\n ORDER BY timestamp DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "timestamp!",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "operation!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "parameters",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "6fcdb09cdd7fedd7e54fdc0e49203f453fc1b85272fe212c0e1bf0ebd28bf58a"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT txid_snapshot_xmin(txid_current_snapshot())::bigint AS \"x!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "x!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "77d8a6d1b6d6cd8def844981a2bf87c66c266526a9fc484aa7f58cbeea6672fd"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, read_only)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7, $8, $9)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Text",
"Bool",
"TextArray",
"Bool"
]
},
"nullable": []
},
"hash": "7f832370916794ab0e5645053688c24678f1519d49ee7263a86dba71d45b8e8c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT unnest(labels) as \"label!\" FROM (\n SELECT labels FROM script WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM flow WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM resource WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM variable WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM schedule WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM app WHERE workspace_id = $1 AND labels IS NOT NULL\n ) t ORDER BY 1",
"query": "SELECT DISTINCT unnest(labels) as \"label!\" FROM (\n SELECT labels FROM script WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM flow WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM resource WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM variable WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM schedule WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM app WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM folder WHERE workspace_id = $1 AND labels IS NOT NULL\n ) t ORDER BY 1",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
null
]
},
"hash": "b782179261b4054289a1a595f6e06ee2035ae9811bc49abce903a04439baf800"
"hash": "7fc4dcc5f46da2a1ac74bf80237a6ae28f1b817b2e1ff275a3f470bb88fc500c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)",
"query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8, $9)",
"describe": {
"columns": [],
"parameters": {
@@ -12,10 +12,11 @@
"Jsonb",
"Text",
"Varchar",
"Jsonb"
"Jsonb",
"TextArray"
]
},
"nullable": []
},
"hash": "977e696c820d1412b56086925a700f59898edc9851e2364485a70071ece7bc1c"
"hash": "813b91a338f2fe46a7d27a155c435f33faa61710388f71f04da2d4569bc3dd71"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT ws.workspace_id AS \"workspace_id!\", entry->'catalog'->>'resource_path' AS dbname\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(\n CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object'\n THEN ws.ducklake->'ducklakes'\n ELSE '{}'::jsonb END\n ) AS dl(k, entry)\n WHERE entry->'catalog'->>'resource_type' = 'instance'\n AND entry->'catalog'->>'resource_path' IS NOT NULL\n UNION ALL\n SELECT ws.workspace_id AS \"workspace_id!\", entry->'database'->>'resource_path' AS dbname\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(\n CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'\n THEN ws.datatable->'datatables'\n ELSE '{}'::jsonb END\n ) AS dt(k, entry)\n WHERE entry->'database'->>'resource_type' = 'instance'\n AND entry->'database'->>'resource_path' IS NOT NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "dbname",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO background_task_state (name, value)\n SELECT $1, jsonb_build_object(\n 'last_xmin', txid_snapshot_xmin(txid_current_snapshot())::bigint,\n 'last_ts', '1970-01-01T00:00:00+00:00')\n WHERE NOT EXISTS (SELECT 1 FROM global_settings WHERE name = $2)\n ON CONFLICT (name) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "8192986cd6106ed060b3d68dbc21e5bb34f5e68a5c6ac455a9d423188af77b23"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM background_task_state\n WHERE name LIKE $1\n AND updated_at < NOW() - INTERVAL '7 days'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "8597cd40f80e69edbf1bc7d7402baca32e33e871be454acb5175c11361fe1b0a"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO background_task_state\n (name, value, running, owner, started_at, finished_at, updated_at)\n VALUES ($1, $2, false, $3, now(), now(), now())\n ON CONFLICT (name) DO UPDATE SET\n value = $2, running = false, owner = $3,\n finished_at = now(), updated_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "8711bb7861cb3c453519a620057e1530039c09b824065027387bbb667a49fe8d"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.path FROM app_script s JOIN app a ON a.id = s.app\n WHERE s.id = $1 AND a.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
false
]
},
"hash": "8816bbe1ea9ea4f359e7a95857d349fa8bd7d23e4589b6936e0d000745cb3f34"
}
@@ -0,0 +1,101 @@
{
"db_name": "PostgreSQL",
"query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, NULL::bool as is_workspace_admin, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", operator as operator_only, is_admin as is_workspace_admin, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "is_workspace_admin",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "verified!",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "super_admin!",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "devops!",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "first_time_user!",
"type_info": "Bool"
},
{
"ordinal": 11,
"name": "role_source!",
"type_info": "Varchar"
},
{
"ordinal": 12,
"name": "disabled!",
"type_info": "Bool"
},
{
"ordinal": 13,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username, is_admin, operator FROM usr\n WHERE workspace_id = $1 AND email = $2 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "operator",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "8d64e61fad7bdf0cc4d4cad1a032e570bdfaf885b4f39ef3ab973256e0448ec7"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM skip_workspace_diff_tally",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "8eb5866b6279cb386bbeb7c387a7c731317199b28694a9c3e04028ef1f1d7500"
}

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