Compare commits

...
Author SHA1 Message Date
windmill-internal-app[bot] 955243ba70 chore: update ee-repo-ref to 970e8adb9bcf315be620fa619a83c62c86844dd4
This commit updates the EE repository reference after PR #544 was merged in windmill-ee-private.

Previous ee-repo-ref: ea10ff498681bd9100a5f4be2fa1e47986fd5b80

New ee-repo-ref: 970e8adb9bcf315be620fa619a83c62c86844dd4

Automated by sync-ee-ref workflow.
2026-05-04 12:13:06 +00:00
Ruben FiszelandClaude Opus 4.7 d8e5a573c7 fix: trim and validate ai proxy header values with clear errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:30:42 +00:00
Ruben Fiszel 03e08f2825 fix pypi 2026-04-29 20:23:32 +00:00
Ruben Fiszelandrubenfiszel e147546b3d chore(main): release 1.693.0 (#8957)
* chore(main): release 1.693.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-29 19:59:25 +00:00
Ruben FiszelandClaude Opus 4.5 abcd920964 test: isolate WAC v2 python test from stack overflow (#8979)
* test: isolate WAC v2 python test from test-thread stack overflow

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

* ci: bump RUST_MIN_STACK to 4MB for backend tests

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-29 19:50:17 +00:00
hugocasaandClaude Opus 4.7 e9e72fbbf8 feat: edit scopes on existing API tokens (#8967)
* feat: edit scopes on existing API tokens

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

* fix: address PR review feedback on token scope edit

- add SECURITY DEFINER to notify_token_scopes_change so trigger fires under windmill_user/admin roles (cubic P1)
- drop banned $bindable(default) on optional props (CLAUDE.md): make ScopesPicker.value and EditTokenScopesModal.open required
- detect MCP only when *every* scope starts with mcp: so mixed/null-scope tokens fall back to standard picker without dropping non-mcp scopes
- audit log scope payload via serde_json instead of Rust {:?}

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-04-29 19:49:56 +00:00
Ruben FiszelandClaude Opus 4.7 de0b6b1528 feat: workspace-shared ui/ folder reusable across raw apps (#8974)
* feat: add workspace-shared ui/ folder reusable across raw apps

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

* feat: add shared ui/ drawer in raw app editor sidebar

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

* feat: forward workspace shared ui/ to raw app editor iframe

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

* all

* all

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:54:55 +00:00
GuilhemandClaude Opus 4.5 5861dcad58 fix(cli): debounce wmill dev flow round-trip 200ms (#8977)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-29 18:02:39 +00:00
Ruben FiszelandClaude Opus 4.7 1169d9bfd3 feat: add delete_after_secs and sensitive_inputs for raw app runnables (#8975)
* feat: add delete_after_secs and sensitive_inputs to raw app policy

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

* chore: simplify sensitive toggle label

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

* chore: use tertiary text for sensitive toggle label

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

* chore: unset sensitive field when toggled off

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

* fix: address PR review feedback

- plumb force_viewer_sensitive_inputs/delete_after_secs so editor preview
  matches deployed-mode encryption
- reuse resolve_delete_after_secs helper for consistency with scripts/flows
- log+ignore schedule_job_deletion errors so a failed schedule doesn't
  surface as an execute_component failure
- fix text-primay typo in CacheTtlPopup and DeleteAfterUsePopup
- tighten extraFields return type to Partial<Pick<...>>

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-04-29 17:15:03 +00:00
hugocasaandClaude Opus 4.7 97b8bb73ab chore: bump protobufjs and other transitive deps in pulumi benchmark (#8972)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:10:47 +00:00
Ruben FiszelandClaude Opus 4.7 8627d3c5ae fix: show skipped label on flow progress bar (#8973)
* fix: show skipped label on flow progress bar for skipped flows

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

* refactor: narrow is_skipped via 'in' operator on Job union

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-04-29 15:53:34 +00:00
centdixandClaude Opus 4.5 4098793db2 fix: split flow prompts for frontend chat (#8968)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-29 14:56:51 +00:00
cec84849b9 feat: OTEL span status on failed jobs + Python stderr severity classification (#8918)
* feat: set OTEL span status on failed jobs and add stderr severity toggle

Record otel.status_code=ERROR and otel.status_description on the job /
job_postprocessing tracing spans when a job fails, so OTel exporters
(Sentry, Honeycomb, Datadog) see the standard span-level failure signal
instead of just the success=false attribute. Description is truncated to
512 chars to keep span payloads bounded.

Add OtelSettings.stderr_default_severity instance setting (error | warn |
info | debug, default error) to let operators downgrade the OTEL severity
used for job stderr output. Python logging routes every record >= WARNING
to stderr, so the historical blind stderr->error mapping produces false
positives for scripts like dlt.

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

* chore: update ee-repo-ref for stderr severity toggle

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

* fix: clarify OTEL span description for already-completed jobs

When handle_queued_job returns Ok(false) on Error::AlreadyCompleted
(another worker already finished the job during a race), the span
was labeled "job returned false" which is opaque to operators.

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

* feat: parse Python logging severity from job stderr

Replace the global stderr_default_severity instance toggle with a
worker-side classifier that recognizes Python's canonical
basicConfig() format (LEVELNAME:logger.name:message) and emits the
corresponding tracing level. Lines that don't match keep the
historical tracing::error! fallback, so genuine failures still
surface and non-Python output is unaffected.

Removes StderrLogSeverity, STDERR_LOG_SEVERITY, and the
otel.stderr_default_severity field; adds
classify_python_logging_line in windmill-common.

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

* test: cover classify_python_logging_line + fix truncate_description doc

Add unit tests for the Python stderr-severity classifier and correct
the truncate_description docstring to say "bytes" (the cap is byte-
based, with UTF-8 boundary rounding for safety). Addresses Claude
review feedback on PR #8918.

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: hugocasa <hugo@casademont.ch>
2026-04-29 14:14:47 +00:00
centdixandClaude Opus 4.5 b883f9a9d2 feat: add ai chat schedule and trigger tools (#8961)
* feat: add ai chat schedule and trigger tools

* refactor: use zod for ai chat workspace tools

* refactor: let ai provide runnable target fields

* refactor: generate ai chat workspace tool schemas

* fix: add object type to composed tool schemas

* fix: avoid top-level trigger schema unions

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

* fix: block undeployed workspace ai tools

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

* fix: inject ai workspace tool target

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

* test: add ai evals for workspace tools

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

* test: make workspace tool eval prompts realistic

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

* fix: surface workspace tool errors

* fix: show workspace tool success details

* fix: describe workspace tool path format

* fix: clarify workspace path examples

* fix: tighten workspace tool validation

* fix: align workspace tool prompts

* chore: mark generated chat schemas

* chore: mark generated cli skills

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-29 14:00:01 +00:00
centdixandClaude Opus 4.5 34b549cfe2 perf: optimize datatable app chat schemas (#8960)
* perf: optimize datatable app chat schemas

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

* perf: optimize datatable catalog queries

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

* refactor: narrow datatable chat optimization

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

* fix: restrict datatable schema lookups

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

* fix: block system datatable schema lookups

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

* fix: handle datatable context edge cases

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

* fix: handle datatable schema edge cases

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-29 13:58:56 +00:00
hugocasaandClaude Opus 4.5 c0eeea9c83 feat: support S3Object input args in native SQL scripts (#8954)
* feat: support S3Object input args in native SQL scripts

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

* fix: review fixes from local-review

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

* update parser

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-28 20:01:06 +00:00
hugocasaandClaude Opus 4.7 c95642863e feat: support restart from steps inside BranchOne, ForLoop, Subflow (#8955)
* feat: support restart from steps inside BranchOne, ForLoop, Subflow

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

* fix: preserve original job kind in nested restart, support expanded subflow steps

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

* fix: read selected iteration from graph state for nested ForLoop restart

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

* feat: iteration selectors per ForLoop in restart popup, more nested restart tests

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

* refactor: extract useNestedRestartState composable

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

* test: cover deployed-subflow + FlowDependencies path in nested restart

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

* chore: update sqlx prepare cache

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

* fix: detect BranchOne/ForLoop ancestors inside expanded subflows for nested restart

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

* fix: hide restart button for non-restartable steps (parallel containers, untaken branches)

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

* fix: address review feedback on nested restart PR

- preview FlowRestartButton: hide nested case (chain UUIDs aren't resolvable in
  preview path; users can use the run page for nested restart instead)
- branchOneAncestorMatchesOriginal: be permissive when status isn't reachable
  (don't hide the button for BranchOnes nested deeper than top-level)
- worker_flow.rs: apply nested_restart_payload swap on the is_simple ForLoop
  fast path too, so simple iterations don't bypass restart spawn interception
- FlowStatusViewer: reset expandedSubflows cache on jobId change; drop
  $bindable({}) banned pattern for the new prop
- API resolver: validate the leaf step exists before returning (fail-fast)
- doc fix: branch_or_iteration_n is 0-based, not 1-based
- selectedJobStepIsTopLevel reset on early-return in composable
- comment iterationCounts collision caveat
- new HTTP-level integration tests covering the API endpoint contract:
  happy path (top-level + nested), unknown step, out-of-range iteration,
  parallel-loop rejection

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

* revert: remove unreachable nested-restart swap on is_simple ForLoop fast path

The swap is unreachable in valid flows: `is_simple_modules` requires the body
to be a single `script` / `rawscript` / `flowscript` (per `FlowModule::is_simple`),
none of which spawn flow-kind children. Any nested-restart chain targeting a
leaf inside such an iteration is rejected by the API at leaf validation. Even
if a chain reached the worker via `JobPayload::RawFlow.restarted_from`, the
resulting `RestartedFlow` would fail to push (script kind isn't a flow kind).

Replaced the swap with an explanatory comment so the next reader knows why
the symmetry with the non-simple path was deliberately not added.

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

* fix: handle undefined expandedSubflows + tighten branchOne match check

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-04-28 20:00:03 +00:00
centdixandClaude Opus 4.5 77d9a53423 fix: strip additionalProperties from google schemas (#8964)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-28 19:59:45 +00:00
hugocasaandClaude Opus 4.7 70b90c41dc fix: prevent React app editor from overwriting files on theme switch (#8965)
* fix: prevent React app editor from overwriting files on theme switch

The ui_builder iframe src embeds the dark-mode flag, so toggling theme
reloads it. iframeLoaded was sticky-true, so the populate effect didn't
refire and the iframe's default "Hello World" template clobbered the
user's files via its initial setFiles message.

Reset iframeLoaded on darkMode change and suppress inbound setFiles from
the iframe until our files are re-pushed.

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

* fix: cancel suppress timer on rapid theme toggles

On a second theme toggle while the previous reload's clear-timer was
still pending, that timer would fire mid-reload and drop suppression
before the iframe finished booting — letting the iframe's default
template setFiles overwrite the user's files.

Track the timer ID, cancel it whenever we re-assert suppression, and
fold the two 500ms timers into one. Race identified by cubic.

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-04-28 19:59:26 +00:00
centdix 866623a39e chore: copy ai env files for webmux worktrees (#8966) 2026-04-28 19:59:07 +00:00
Ruben FiszelandClaude Opus 4.5 1d279e7a1e feat: add min release age instance settings for bun and uv (#8956)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-27 22:45:53 +00:00
Ruben Fiszelandrubenfiszel b2004f357d chore(main): release 1.692.0 (#8950)
* chore(main): release 1.692.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-27 20:22:48 +00:00
eebe24d8b0 feat(cli): wmill dev with per-flow proxy and responsive Dev UI (#8529)
* feat(cli): add `wmill flow dev` subcommand with per-flow reverse proxy and launch.json

Also generates .claude/launch.json for existing flow folders during `wmill init`.

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

* feat: responsive dev layout and hide splitter for single-pane views

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

* fix: clamp flow graph height between minHeight and maxHeight

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

* feat(cli): enhance app new with Claude Desktop integration and better defaults

- Add .claude/launch.json to generated app scaffold for Claude Code preview support
- Add "Open in Claude Desktop?" prompt that creates a CLI session and opens it
  in Claude Desktop Code mode via the claude://resume deep link
- Improve default CSS template with body background, system fonts, and padding

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

* fix(cli): handle both .flow and __flow suffixes in wmill dev

The flow detection in loadPaths only checked the configured suffix
(dotted or non-dotted), so users with nonDottedPaths=true who had
.flow folders (or vice versa) would see inline script edits treated
as standalone script changes instead of flow changes.

Now checks both suffix forms everywhere: type classification,
folder path extraction, path stripping, and loadWmPath lookup.
Also adds raw_app launch.json generation to init and sync pull.

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

* docs(cli): update generated skills with dev workflow and preview commands

Update cli-commands, write-flow, and raw-app skills to document the new
local dev workflow (wmill dev --path, --proxy-port, .claude/launch.json).
Add wmill script preview and wmill flow preview to all script/flow skills
so agents know how to test without deploying.

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

* fix(cli): include path in dev URL and use open.default for browser

- Append &path= to the printed/opened URL when --path is specified
- Use open.default(url) instead of open.openApp for more reliable browser opening

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

* feat(cli): add Claude CLI/Desktop detection hints in wmill flow new

Show contextual instructions for previewing flows based on available tools.

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

* chore: regenerate auto-generated CLI skills for new dev flags

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

* fix(cli): handle mixed flow suffixes in dev file watcher

The ignore() function uses isFlowPath() which only checks the configured
suffix (__flow or .flow), causing files in the other variant to be silently
ignored. Bypass the ignore check for any file inside a flow folder and
force flow type detection regardless of suffix configuration.

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

* feat(cli): drop default proxy in flow folders, open browser, add --no-browser

Manual `wmill dev` in a flow folder should not implicitly enable the
reverse proxy. Both proxy and legacy modes now open the browser; the
new --no-browser flag opts out. Claude Code launch.json templates pass
--no-browser so the IDE preview doesn't fight a system browser window.

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

* feat(cli): gate dev broadcasts by --path and push currentLastEdit on connect

When --path (or auto-detected flow path) is set, drop file events for
any other path so the dev page stays locked to the requested resource
and currentLastEdit can never reflect an unrelated edit. The connection
handler proactively pushes currentLastEdit so the page renders without
waiting for the first file change.

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

* fix(frontend): prefer WebSocket for flow round-trip when wmill dev is connected

updateFlow used isInIframe priority, which routed Claude Code's iframe
preview through postMessage (no listener) and silently dropped flow
edits. Flip the priority: when the wmill dev WebSocket is open, use it
(covers standalone tabs and Claude Code's preview); fall back to
postMessage only when no WS is connected (the VS Code extension's iframe
URL has no `local=true`, so it never opens one). Also stop assigning
lastSent before a channel actually accepted the message, so a CONNECTING
WS doesn't silently swallow the first change.

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

* feat(dev): url is source of truth for path; add workspace file picker

Drops the server-side --path gate added in 3c2d5155e1. The dev page now
filters by its URL's ?path= and the CLI is a dumb broadcaster, which
lets multiple tabs each watch different paths. When the URL has no
?path=, the page asks the CLI for a list of workspace items (flows,
scripts, raw_apps) via a new {type:'listPaths'} WS message and renders
a picker. Clicking a flow or script soft-updates the URL via
history.pushState and loads it; raw_apps surface a hint to use
`wmill app dev` since they don't render here.

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

* feat(dev): picker uses homepage tree view with summaries

Replace the hand-rolled Button-list picker with a TreeView-style layout
that mirrors the Windmill homepage: folder/user tree grouping via
`groupItems`, item rows rendered through the shared `Row.svelte` (no
actions, no favourites, no link — just the visual), a `SearchItems`
fuzzy filter with the same search input styling and placeholder as the
homepage, and `group-open:` chevron toggling on native <details>.

The CLI's listWorkspacePaths now also reads summaries from each item's
metadata (flow.yaml for flows, <script>.script.yaml for scripts) in
parallel so the picker shows summaries as the primary row label, same
as the homepage. Raw apps have no standard manifest so they show the
path only.

Additional polish: title shows "<workspace> (local)" instead of
generic text, subtitle trimmed, item-wrapper owns the border-b so
Row's internal last:border-b-0 doesn't zero it out, summary border
gated on group-open: to avoid doubled lines when a folder is
collapsed.

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

* fix(cli): wmill dev --no-browser was a no-op

Cliffy's `.option("--no-browser", ...)` creates an option named
`browser` (boolean, default undefined) that becomes `false` when the
flag is passed. The previous code checked `opts.noBrowser`, which
Cliffy never populates, so the guard silently no-op'd and the browser
always opened. Rename to `browser` and check `=== false` explicitly,
matching the `wmill app dev --no-open` convention.

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

* feat(dev): picker warns when wmill dev server is unreachable

Track WS state in Dev.svelte (connecting/open/closed) — 'closed' is
set on either the WS error or close event. When closed, the picker
replaces the toggle + search + tree with a warning Alert telling the
user to run `wmill dev` from the workspace root. Toggle and search are
hidden rather than rendered disabled because there's nothing to filter
anyway.

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

* refactor(cli): rename wmill dev --no-browser to --no-open

Match the pre-existing `wmill app dev --no-open` flag. Having
`--no-browser` on one dev command and `--no-open` on the other was
just an oversight from my earlier change. All three launch.json
templates (init, flow new, sync pull) switch to `--no-open`.

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

* feat(cli): wmill init creates root .claude/launch.json for the picker

Adds a workspace-root .claude/launch.json so Claude Code can launch
`wmill dev` from the project root and land on the file picker (no
--path → picker mode). Per-flow and per-raw_app launch.json files are
already generated by the existing scans. Skipped (with a gray log) if
the file already exists, so the user's customizations are preserved.

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

* feat(cli): add skipClaudeAssets wmill.yaml flag

When `skipClaudeAssets: true` is set in wmill.yaml, all generators
that previously wrote Claude-specific assets become no-ops:

- writeAiGuidanceFiles skips CLAUDE.md and .claude/skills/
  (AGENTS.md is still written — vendor-neutral)
- wmill init skips the root .claude/launch.json + per-flow +
  per-raw_app launch.json scans
- wmill sync pull skips the per-flow + per-raw_app launch.json scans
- wmill flow new skips the new flow's .claude/launch.json
- wmill app new skips the new raw_app's .claude/ folder + launch.json

The flag is added to SyncOptions, DEFAULT_SYNC_OPTIONS, and the
generated wmill.yaml template (commented out — opt-in).

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

* feat(cli): wmill init removes existing Claude assets when skipClaudeAssets is set

Re-running `wmill init` with `skipClaudeAssets: true` now removes
previously-generated Claude assets so the workspace state matches the
config. Narrow scope, no confirmation:

- per-flow / per-raw_app .claude/launch.json (each parent .claude/
  collapsed if empty)
- root .claude/launch.json
- .claude/skills/ (wholly ours; safe to remove the subtree)
- root .claude/ collapsed if empty
- CLAUDE.md only if its content matches the default
  ("Instructions are in @AGENTS.md\n"); otherwise left in place
  with a note

Each removal is logged in yellow under a single gray intro line that
prints lazily on the first removal — a clean tree stays silent.

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

* fix(cli): wmill workspace add browser open silently no-ops

`open.openApp(open.apps.browser, { arguments: [url] })` resolves its
Promise even when the OS-level launch does nothing, so the CLI prints
"Opened browser for you" but no tab appears. Same pattern was already
fixed in `dev.ts` by commit 3272c29c2e — use `open.default(url)`,
which delegates to the native URL opener (`open` on macOS, `xdg-open`
on Linux, `start` on Windows) and actually rejects on failure.

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

* fix(cli): wmill init workspace prompt no longer duplicates active profile name

Cliffy's Select.prompt renders `default: X` as `(X)` next to the
question header, which duplicates whichever workspace name the
default points to. Drop `default` and instead reorder the list so
the active profile is first (cursor-preselected by virtue of position)
and append "— active" to its label so the indicator lives where it's
contextually relevant.

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

* docs(skills): expand preview-vs-run guidance for write-flow + all write-script-* skills

Both `wmill flow preview/run` and `wmill script preview/run` have the
same intent split — preview hits the local file, run hits the deployed
version, sync push deploys. The skills' "after writing" sections used
to terse-list the commands and just say "do not run them yourself",
which encouraged the wrong reflex of `sync push` + `run` to "test".

Rewrite the section in both `system_prompts/base/flow-base.md` (drives
write-flow) and the `script_cli_intro` block in
`system_prompts/generate.py` (drives all write-script-<lang>) to:

- explicitly list `preview` as the default for local iteration,
- spell out the few cases when `run` or `sync push` are appropriate,
- offer to test as a one-sentence next step (no multi-option menus),
- mark `preview` as safe to run autonomously.

Regenerate auto-generated/ + cli/src/guidance/skills.ts.

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

* refactor(cli): wmill dev — clearer mode names and accurate startup messaging

- Rename `startLegacyServer` to `startDirectServer`. "Legacy" implied
  it was on the way out; the two modes (proxy vs direct WS) actually
  serve different topologies and both stay. Add comments above each
  section spelling out who they're for: proxy mode for embedders that
  require a localhost origin (Claude Code preview), direct mode for
  standalone browser tabs and the VS Code extension iframe.

- Replace the stale "Dev server will automatically point to the last
  script edited locally" log line. Now print path-aware text:
  - with --path (or auto-detected): "Watching <path> — edits will live
    -reload in the dev page"
  - without: "Open the dev page and pick a flow or script to preview —
    edits will live-reload" plus a hint about --path
  Mirror the same in proxy mode after the listen callback.

- Drop the redundant "Go to <url>" line when --no-open isn't passed
  (maybeOpenBrowser already prints "Opened browser at <url>").

- Rename "Server listening on port 3001" to
  "Dev WebSocket listening on ws://localhost:<port>/ws" so the line's
  purpose is obvious.

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

* refactor(cli): drop per-folder .claude/launch.json generation

Stop creating `.claude/launch.json` inside every flow folder, raw_app
folder, and at `wmill flow new`/`wmill app new` time. The workspace-
root `.claude/launch.json` from `wmill init` stays — it's the picker
entry point and the one place where the deterministic "click → preview"
UX is high-value.

Removed from:
- `wmill init` — per-flow + per-raw_app scans
- `wmill sync pull` — per-flow + per-raw_app scans (also drops the
  now-unused `node:fs` mkdirSync/writeFileSync import)
- `wmill flow new` — bootstrap no longer scaffolds `.claude/`
- `wmill app new` — same; also drops the `.claude/launch.json` lines
  from the post-create directory listing

Skills already give the agent the right CLI commands, so per-folder
launch.json was redundant context. Existing files in user projects
keep working but won't be regenerated; `wmill init` with
`skipClaudeAssets: true` cleans them up.

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

* feat(cli): wmill app new flags + tighten raw-app skill for AI agents

`wmill app new` is interactive by default, which hangs forever when an
AI agent tries to use it. Add flags so the wizard can be bypassed
end-to-end:

- `--summary <text>`, `--path <path>`, `--framework <react19|react18|
  svelte5|vue>` (required for non-interactive)
- `--datatable <name>` (opt into the datatable wizard)
- `--schema <name>` (creates schema with CREATE SCHEMA IF NOT EXISTS
  if it doesn't already exist; only valid with --datatable)
- `--overwrite` (replace existing directory without prompting)
- `--no-open-in-desktop` (suppress the Claude Desktop offer)

Mode is auto-detected: providing any of --summary/--path/--framework
puts the run into non-interactive mode where the datatable wizard,
overwrite prompt, and Claude Desktop prompt all skip silently (or fail
fast on conflict instead of waiting for stdin). Each provided flag is
validated upfront with a clear error message.

Skill side: rewrite `system_prompts/base/raw-app.md`'s "Creating a Raw
App" section so the AI agent knows it should run the command itself
with flags (not tell the user to run it interactively). Direct the
agent to use `AskUserQuestion` with one bundled call to gather any
missing summary/path/framework — refuse to invent values, refuse to
default. Anti-patterns spelled out explicitly.

AGENTS.md template (`cli/src/guidance/core.ts`) had a contradicting
line ("MUST ask the user to run wmill app new in its terminal first")
that was loaded eagerly into agent context and overrode the skill —
replaced with the same agent-driven guidance, pointing to the
raw-app skill for the full procedure.

Regenerate auto-generated/ + cli/src/guidance/skills.ts.

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

* feat(cli): per-target preview launch.json + agent uses wmill flow new

Refactor the agent's dev/preview workflow:

- Drop root .claude/launch.json generation from `wmill init`. Sharing one
  generic entry across sessions caused preview collisions; agents now add
  per-target named entries (windmill: <wmill_path>) on demand.
- New `preview` skill in system_prompts/base/preview.md. Branches on
  whether `mcp__Claude_Preview__*` MCP tools are available: with them,
  add a per-target launch.json entry pinning its own port + --proxy-port
  + --path + --no-open and invoke the MCP preview tool; without them,
  start `wmill dev --path <X> --no-open` directly and hand the URL the
  CLI prints to the user. Never touch launch.json in the direct case.
- Agents must run `wmill flow new <path>` themselves to scaffold flows
  (folder + flow.yaml with the right suffix), parallel to the existing
  `wmill app new` rule. Missing path/summary trigger AskUserQuestion;
  no inventing values.
- write-flow skill: 4-step Creating a Flow procedure that opens the
  visual preview *before* editing flow.yaml so the user watches the
  flow take shape via live reload.
- `wmill flow new` always prints the `wmill dev --path <X>` preview
  hint; drop the Claude CLI/Desktop detection branches.

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

* docs(skills): open app preview before editing in raw-app skill

Mirrors the flow skill's Step 3 — opening `wmill app dev` via the
preview skill before touching App.tsx so the user watches the app
take shape via live reload, instead of seeing the finished result
at the end.

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

* fix(dev): guard WS replaceFlow with lockChanges to prevent echo

The postMessage handler at Dev.svelte:306-312 wraps replaceFlow with
lockChanges = true (cleared 500 ms later) so the $effect on
flowStore.val doesn't immediately re-serialize and re-send the freshly
received payload. The WebSocket handler did not, so on the initial
flow push (dev.ts:568-574 sends currentLastEdit on connect), the
client would echo back to handleFlowRoundTrip, which runs the
orphan-file scan. On content equality the write was a no-op, but the
scan could still delete files the server did not list.

Mirror the same lockChanges/timeout pattern in the WS replaceData
handler. Apply to both flow and script paths for symmetry.

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

* fix(cli): correct wmill dev description + gate broadcasts server-side

Two related fixes:

1. The 'auto-pushes them to the remote workspace' wording in the
   wmill dev description was wrong — the command never deploys, it
   only broadcasts file changes over WS for live preview. Reworded
   to call this out explicitly and point at 'wmill sync push' for
   the deploy case.

2. Move the path filter out of the client (Dev.svelte:491-495) and
   into broadcastChanges. Earlier the filter was client-side with
   the comment 'server stays a dumb broadcaster' even though commit
   3c2d5155 was titled 'gate dev broadcasts by --path'. Doing the
   compare server-side aligns the implementation with the commit
   narrative, cuts WS traffic when --path is set, and keeps the
   per-tab semantics for the picker (each picker tab still gets the
   full 'paths' listing on first connect).

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

* fix(cli): drop dead launch.json cleanup, fix description, narrow orphan scope

Three review fixes:

1. cleanupClaudeAssets removed both root and per-folder
   .claude/launch.json files that this CLI never generates anymore.
   Per the user's "feature hasn't been released yet" guidance, no
   migration is needed — drop the dead scan and the root rm. Also
   drop the now-unused nonDottedPaths argument (and its flowSuffix
   / rawAppSuffix locals).

2. The skipClaudeAssets description in template.ts listed
   .claude/launch.json among the assets it skips, but launch.json
   is no longer generated. Drop it from the description string.

3. The dev round-trip's orphan cleanup deleted any non-dot file in
   a flow folder that wasn't in extractedPaths — including
   README.md, fixtures, TODO.md, etc. Restrict the deletion to
   files whose extension is in a known inline-script set
   (.ts/.js/.py/.go/.sh/.sql/.ps1/.php/.rs/.java/.cs/.r/.graphql).

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

* refactor(cli, frontend): dedupe flow suffix helpers, use UI components

Five small follow-ups from the PR review:

1. dev.ts already had stripFolderSuffix() but three callsites were
   reimplementing the same .flow/__flow if-else inline. Add an
   isFlowFolderName(name) helper next to it and replace the duplicates
   in startProxyServer's cwd check, the file-watcher localPath strip,
   and normalizeWmPath.

2. Dev.svelte:866 was a <div onclick> with two svelte-ignore comments
   for the missing a11y handlers. Replace with a real <button
   type="button"> — kills the warnings, no visual change.

3. Dev.svelte:1283 was a raw <input type="text"> for the module
   summary. Replace with the existing <TextInput> component (same one
   the picker search at :1010 uses), per frontend/CLAUDE.md.

4. Dev.svelte:197 typed relativePaths as any[]; tighten to the actual
   union (string | [number, string])[] — the python helper returns
   tuples, the typescript one returns strings.

5. app/new.ts:822 fired exec("open <deeplink>") with no callback, so
   an OS that refused the URL scheme silently failed and we still
   logged "Opened in Claude Desktop!". Move the success log inside an
   exec callback that surfaces the error and prints the deep link for
   manual opening.

Plus a brief comment above parseWatchPath explaining its resync
contract (initial load + popstate + explicit pickPath, no generic
pushState listener).

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

* fix(cli): regenerate auto-gen for dev description; drop apostrophe to satisfy parser

generate.py:326 extracts .description() with the regex
[^"\']+ which bails on either quote type. Commit ff3a8e4ebd's new
description had an apostrophe inside double quotes ('wmill sync
push'), so the parser saw no description at all and the
auto-generated files dropped the line entirely — which is what
check-freshness caught on origin/main.

Quickest path to green CI: rephrase the description without the
inner apostrophe, then regenerate. The generator's regex is the
real bug but fixing it is out of scope here.

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

* feat(cli): seed app .claude/launch.json before opening Claude Desktop

When the user accepts "Open in Claude Desktop?" in wmill app new, write
a per-app .claude/launch.json (named "windmill: <appPath>") into the
freshly-created app folder before the deep link fires. Entry runs
'wmill app dev --no-open --port ${PORT:-4001}' from the app folder
(which is the cwd Claude Desktop opens with), so the user can hit play
right away to launch the preview.

Skip if .claude/launch.json already exists — never clobber user edits.

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

* fix toggles positions

* fix(dev): gate picker mode on ?local= so VS Code iframe still renders content

The VS Code extension iframe loads the dev page without ?path= and
without ?local=true. After the picker rework, an empty watchPath
flipped pickerMode on, so the page rendered the picker UI even
though the extension was sending replaceScript / replaceFlow
postMessages — leaving the user stuck on the picker forever.

Picker mode only makes sense on the local dev page, where the wmill
dev WebSocket can supply the workspace listing. Anywhere else (VS
Code iframe, plain remote tab) the picker has no data source and no
purpose. Add an isLocalDevPage check so the picker only shows when
?local=true is present.

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

* refactor(dev): mirror vscode extension's processFlowMessage round-trip

Three changes that bring our wmill dev round-trip into lockstep with
the windmill-vscode extension's processFlowMessage in src/extension.ts:

1. New cli/src/commands/dev/pathscript-restore.ts — verbatim port of
   the extension's src/utils/pathscript-restore.ts. Adds AI-agent tool
   walking that the previous local copy was missing (flows with
   PathScript-shaped tools weren't being preserved across round-trip).
   Header comment makes the cross-repo link explicit.

2. handleFlowRoundTrip rewritten to mirror processFlowMessage step-
   for-step: reads failure_module + preprocessor_module from the
   current flow.yaml, passes them to extractCurrentMapping, shares one
   pathAssigner across all extraction calls, extracts inline scripts
   from those special modules too, skips writing files whose content
   starts with !inline (treats as pointer directives), and only
   rewrites flow.yaml when the serialized YAML actually differs.

3. snapshotPathScripts / tagReplacedPathScripts callsites in loadPaths
   were passing the FlowFile wrapper instead of FlowFile.value — the
   helpers walk .modules / .failure_module / .preprocessor_module,
   which only exist on .value, so PathScript snapshots silently
   no-op'd on the file-watcher path. Pass .value at all four sites.

Deliberate divergence from the extension: orphan-cleanup keeps the
INLINE_SCRIPT_EXTS allow-list so README.md / fixtures aren't deleted.
The extension's version still over-deletes; that's tracked separately.

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

* docs(skills): offer visual preview after create instead of auto-opening

Both write-flow and raw-app skills used to instruct the agent to open
the visual preview without asking right after wmill flow new /
wmill app new, on the rationale that live reload is most useful when
the page is already up. In practice this surprised users — opening
the dev page has side effects (browser window pop, possibly a
launch.json entry under MCP-preview Branch A) that warrant consent.

Change Step 3 in both skills from "open it without asking" to "offer
it as a one-sentence next step" — same pattern the same skills
already use for programmatic wmill flow preview offers. Two then-
necessary anti-patterns ("just open it", "open it before editing")
are dropped along with the auto-open instruction.

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

* fix(cli): probe both ip stacks before binding wmill dev proxy / app dev port

Node's default listen() has platform-dependent dual-stack behaviour.
If the requested port is already held on the IPv4 stack, listen() can
silently fall back to binding IPv6-only ([::1]:N). The OS then routes
new localhost connections to the older IPv4 listener, so the user
opens http://localhost:N and sees a stale prior server with no signal
that anything is wrong. Bit us in practice: a leftover wmill dev
--proxy-port 4000 served traffic for a freshly-started wmill app dev
--port 4000.

New helper at cli/src/utils/port-probe.ts probes both 0.0.0.0 and ::
before binding. On collision it walks upward to the next free port
(up to +20) and logs a prominent warning naming the holder when lsof
/ ss can find it:

  Port 4000 is already in use (held by PID 91418 `bun`). Using
  port 4001 instead.

Wired into:
- wmill dev --proxy-port: the resolved port flows into both
  proxyServer.listen() and the &port=N parameter in the redirect
  URL, so they always match. Bind explicitly to 0.0.0.0.
- wmill app dev --port: only when the user passed --port explicitly
  (the default getPort.default(...) path already handles fallback).

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

* fix(dev): pass placeholder via TextInput inputProps not as top-level prop

`<TextInput>`'s top-level Props don't include `placeholder` — native
input attributes go through the `inputProps` field. The previous
`<TextInput placeholder="Summary" .../>` failed `npm run check` with
"Object literal may only specify known properties, and
'\"placeholder\"' does not exist in type 'Props<\"input\">'.".

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

* nit

* fix(cli): sequential port probe + sync dev test regex with renamed log

Two CI regressions on test-linux:

1. port-probe parallel race on Linux. isPortFreeOnBothStacks ran the
   IPv4 and IPv6 binds via Promise.all. On Linux the default is
   net.ipv6.bindv6only=0, so a bind(::, port) socket also takes the
   IPv4 stack on the same port. Concurrent v4 + v6 binds then race for
   v4 — one wins, the other gets EADDRINUSE on a port that is actually
   free. Walks 20 ports up, all fail the same way, throws, child exits.
   Tests that fetch http://localhost:port time out at 60s.
   Doesn't repro on macOS (bindv6only=1 by default — what I tested
   against). Probe sequentially so each bind fully releases before the
   next starts.

2. dev_server.test 1 regex out of sync. Commit 018dc3861a renamed the
   startup log from "Server listening on port N" to "Dev WebSocket
   listening on ws://localhost:N/ws" but didn't update the test, which
   times out at 30s waiting for the old string. Update the regex to
   match the current log.

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

* Update system_prompts/auto-generated/skills/write-script-graphql/SKILL.md

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix(cli): address dev/app PR review — bugs 1-7

Per code review:

1. app new.ts — wrap claude --session-id exec in try/finally so the
   spinner setInterval is always cleared. On rejection control jumped
   to the outer catch and the spinner kept writing \r forever, garbling
   subsequent output.

2. app new.ts — make --overwrite actually wipe the dir before
   re-creating. Previously logged "Overwriting" but only skipped the
   prompt; leftover files from a different framework (e.g. App.tsx
   from a prior react18 install when re-scaffolding as svelte5)
   survived and produced a hybrid scaffold.

3. dev/dev.ts — anchor the flow-folder match on path segments. The
   substring checks (cpath.includes(".flow/") / "__flow/") also fired
   on names like notes_about__flow_design/readme.md. New
   isInsideFlowFolder + findFlowFolderPrefix split on "/" and check
   segment suffixes. Drops the now-unreachable script→flow fallback
   inside the else branch.

4. dev/dev.ts — direct mode also routes through resolveBindPort so it
   detects dual-stack collisions like the proxy mode does. Bare getPort
   only probes one stack, defeating the whole point of port-probe.ts.
   Also bind to BIND_HOST explicitly. Drops the unused getPort import.

5. dev/dev.ts — normalize opts.path once after mergeConfigWithConfigFile.
   broadcastChanges compared against a non-normalized opts.path, so
   --path f/foo/ or --path f/foo.flow silently dropped every broadcast.
   Also pulls normalizeWmPath to module scope (was a closure inside dev()).

6. dev/dev.ts — guard the initial-state ws.send with readyState === OPEN,
   matching the other branches' pattern.

7. dev/dev.ts — typo: "givena" → "given a".

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

* fix(cli): address dev/app PR review — items 8-10

8. dev/dev.ts — derive INLINE_SCRIPT_EXTS from exts so adding a new
   script language to script.ts auto-extends orphan cleanup. Previously
   .gql, .nu, .rb were missing — flows using those languages would
   leave orphaned inline files behind. Excludes .yml because user
   fixtures commonly use it in flow folders, and leaving a stale
   .playbook.yml inline script is preferable to deleting a fixture.
   Keeps .js for hand-written flows that aren't in the exts list.

9. app/new.ts — wrap Claude Desktop install probe + prompt in
   process.platform === "darwin". The probe (ls /Applications/Claude.app)
   and the open command both only work on macOS — the explicit guard
   makes the platform scope grep-able.

10. app/new.ts — switch the deep-link spawn from exec(`open ${shell-
    escaped url}`) to execFile("open", [deepLink]). sessionId is a UUID
    and absAppDir is URI-encoded today so the old form was safe, but
    execFile removes the shell entirely.

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

* fix(cli,dev): cubic review — port-probe error semantics, pluralize spacing

[2] cli/src/utils/port-probe.ts — distinguish IPv6-unsupported from port
collision in isPortFree. Previously every error code returned false,
including EAFNOSUPPORT / EADDRNOTAVAIL on the IPv6 probe when the host
has no v6 stack at all (IPv4-only containers). resolveBindPort would
then walk all 20 ports getting the same error and throw. Treat only
EADDRINUSE / EACCES as "not free"; everything else as free.

[13] cli/src/commands/app/dev.ts — only probe both stacks when binding
to localhost. The dual-stack collision risk is specific to localhost
(which resolves to 127.0.0.1 + ::1); for an explicit IPv4 host there's
only one stack to worry about, so don't move the user's requested port
over a phantom v6 collision.

[14] frontend/src/lib/components/Dev.svelte — pluralize already inserts
a space between quantity and word, so " item" produced "3  items".
Drop the leading space in both call sites.

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

* docs(system_prompts): cubic review — preview args, skill scopes

Source changes in base/ + generate.py, then regenerated auto-generated/
via python system_prompts/generate.py. Per cubic review:

[4]+[7] generate.py — "pick plausible args from the `main` signature"
was language-blind. SQL queries and Bash scripts use $1/$2 positional
parameters, not a main(...) signature. Reword to call out both shapes
explicitly so the wording survives across all 19 generated language
skills (postgresql, bash, mysql, …) instead of just the ones that
happen to have main().

[5] base/raw-app.md — the "CLI Commands" table said "Tell the user
they can run these commands (do NOT run them yourself)" while the
"Creating a Raw App" section above (added in this PR) tells the agent
to run `wmill app new` itself. Carve `wmill app new` out of the table
and add a one-line note pointing back to the create flow, so the
guidance no longer self-contradicts.

[10] base/preview.md — "These print a `Go to <url>` line on stdout"
was wrong for `wmill app dev`, which prints
"🚀 Dev server running at <url>". List both line shapes explicitly and
suggest a loose http:// match for URL capture.

[12] base/flow-base.md — "regenerate lock files for the flow you
modified" misstated the default scope. `wmill generate-metadata`
scans scripts, flows, and apps by default
(see cli/src/commands/generate-metadata/generate-metadata.ts:71-73).
Update wording to call out the default scope and how to narrow it.

Also folds the cubic [1] graphql safety wording (originally a one-off
edit on the auto-generated file in a895db7) back into generate.py
itself, so it survives regeneration and applies to all language skills.

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

* docs(system_prompts): cubic round 2 — language-specific placeholder syntax

Round 1 wording was too narrow:

- "$1, $2 placeholders for SQL queries and Bash" was wrong for MySQL
  (`?`), Snowflake (`?`), MSSQL (`@P1`), BigQuery (`@name`), and
  PowerShell (which uses `param(...)`, not main()).
- The preview-skill URL match said "first `http://...` token" — remote
  workspaces serve HTTPS, so the regex would miss them.

Source-only fixes in generate.py and base/preview.md, then regenerated
auto-generated/ via python system_prompts/generate.py.

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

* fix(flow): track maxHeight in FlowGraphV2 height effect

cubic [3]: updateHeight() reads both minHeight and maxHeight, but the
$effect only tracked minHeight. Changing maxHeight alone (e.g. when a
parent shrinks the cap during a layout transition) left height frozen
at the previously clamped value.

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

* docs(system_prompts): tool-agnostic wording in base/

cubic [11]: system_prompts/README.md says these prompts must NOT
contain tool usage instructions. Three base files violated this:

- base/flow-base.md (4× AskUserQuestion). Worst offender — leaks into
  the frontend copilot via FLOW_BASE in prompts.ts (consumed by
  getFlowPrompt in frontend/src/lib/components/copilot/chat/flow/
  core.ts:1287). Frontend has no AskUserQuestion tool, so the wording
  was both irrelevant and confusing there.
- base/raw-app.md (5× AskUserQuestion + 1× mcp__Claude_Preview__).
  CLI-skill-only but covered by the same scope rule.
- base/preview.md (5× mcp__Claude_Preview__). CLI-skill-only, same.

Replaced with role descriptions: "ask the user (use a structured-
question tool if your runtime has one)" and "a tool that can embed a
localhost URL inside the IDE / chat surface". Kept one mention of
mcp__Claude_Preview__ in preview.md as an illustrative example, since
documentation of one runtime is fine — what's not fine is gating
behaviour on a specific tool name.

Source-only edits, then regenerated auto-generated/ via
python system_prompts/generate.py.

Verification: grep -r AskUserQuestion system_prompts/auto-generated/
now returns nothing. The remaining AskUserQuestion refs in
cli/src/guidance/core.ts are hand-written CLI-only AGENTS.md content
(not part of system_prompts), and Claude Code does have that tool, so
those are correctly scoped.

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

* fix(flow): drop .no-splitter CSS hack, use snippets to skip Splitpanes

cubic [8]: the previous fix for "top pane is empty in aiagent / noEditor
mode" was a CSS rule that hid `:global(.splitpanes__splitter)` inside
.no-splitter. That cascaded into nested splitpanes too — the aiagent
left/right tabs panel (line 1043), the debug-console editor split
(line 877), and the doubly-nested debug panel (line 1472) all lost
their resize handles.

Refactor the layout instead. Extract top-pane and bottom-pane content
as snippets, then conditionally render either:
  - just the bottom snippet (no Splitpanes wrapper) when the top pane
    would be empty (aiagent or noEditor), or
  - the original two-Pane Splitpanes layout otherwise.

This removes the splitter at its root rather than hiding it, so
nested splitters are unaffected. The bottom Pane's complex bind:size
getter/setter (which returned 100 when aiagent) collapses to a simple
binding now that the aiagent path no longer goes through the wrapping
Pane at all.

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

* nit

* fix(flow,preview): cubic round 3 — FlowPathViewer regression + preview skill rewrite

[3149192182] FlowModuleComponent.svelte: my last refactor's
"aiagent || noEditor" condition stripped the FlowPathViewer for
noEditor + type === 'flow', because the top-pane snippet was no longer
rendered. The flow-viewer pane is the only thing that *does* show in
that mode, so it shouldn't have been collapsed. Tighten the condition
to "aiagent || (noEditor && type !== 'flow')".

[3149060930] system_prompts/base/preview.md: Branch A detection was
too broad — "can embed or open a localhost URL" is strictly weaker
than "can read .claude/launch.json and launch a configuration". Only
the Claude Desktop / Code MCP integration does the latter; most
embedders only do the former. Restructure preview.md around two
orthogonal axes:

  1. Mode (proxy vs direct) — driven by "does the embedder need a
     localhost URL?". Direct is the default; proxy is for embedders
     that sandbox cross-origin loads.
  2. Who starts the server — you spawn `wmill dev` yourself, OR a
     launch.json-aware runtime (currently only the
     `mcp__Claude_Preview__*` MCP family) launches it on demand.

The two compose into four common cases (regular browser tab, generic
preview pane, localhost-only preview pane, Claude MCP), each with a
clear instruction. The launch.json/MCP machinery is now scoped to a
single section gated on actually having that tool in your tool list.

Source-only edit in base/preview.md, then regenerated auto-generated/
via python system_prompts/generate.py.

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

* Fix dev step display

* nit

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-27 20:14:21 +00:00
Ruben FiszelandClaude Opus 4.7 e636f589a5 fix: prevent flow-dep job stalls under row-lock contention (#8952)
* refactor: split flow-dep job tx so subprocesses don't hold row locks

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

* feat: link flow version from run page to pinned flow viewer

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

* fix: time-out dep job phase 1/3 db ops and surface error on flow page

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

* fix: dissolve dep_map in phase 1 and recheck flow version unconditionally

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

* fix: address PR review — view-latest reload, decimal truncation, app version

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

* fix: keep dissolve in phase 3 for relative-import dep jobs

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

* chore: trim verbose comments and refresh sqlx offline cache

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

* fix: address cubic — propagate dissolve errors, include workspace in reload key

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-04-27 16:03:57 +00:00
GuilhemandClaude Opus 4.7 02f1581e5b align folders page empty state with table style (#8920)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:53:01 +00:00
Ruben FiszelandClaude Opus 4.7 581658d881 fix(wac): recognize @workflow main, list WAC in scripts/list, run preprocessor (#8951)
* fix(wac): recognize @workflow main, list WAC in scripts/list, run preprocessor

Three workflow-as-code bug fixes:

- #8945: Python WAC template with `@workflow async def main(...)` was not
  detected as `auto_kind = "wac"`. The detection only ran when no `main`
  function was found. Hoist the heuristic so it runs whether or not `main`
  is the entrypoint.

- #8946: `scripts/list?kinds=script` filtered out WAC scripts because they
  set `auto_kind = 'wac'` and the SQL hid everything that wasn't NULL.
  Allow both NULL and 'wac' (still excluding 'lib' library scripts).

- #8947: Preprocessor functions defined alongside a WAC workflow were
  ignored. Inject the preprocessor invocation into the Python WAC wrapper
  so it runs before the workflow on the first iteration, then plumb the
  preprocessed args through `handle_wac_v2_output` so inline child
  re-runs see the post-preprocessor args via `checkpoint.input_args`.

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

* test(wac): integration tests for #8946 (scripts/list) and #8947 (preprocessor)

- test_scripts_list_includes_wac: hit GET /scripts/list?kinds=script and
  assert WAC scripts are in the response (would have failed pre-#8946 fix
  because of the auto_kind IS NULL filter).
- test_python_wac_v2_with_preprocessor: deploy a Python WAC script with a
  preprocessor, run with raw event args, assert the workflow saw the
  preprocessed shape and v2_job.args/preprocessed were updated.
- New wac_preprocessor.sql fixture with auto_kind = 'wac' set explicitly.

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

* fix(wac): address PR review feedback

Five review fixes:

- python_executor.rs: WAC preprocessor now runs inside the wrapper's
  `try:` block so failures route through the same `result.json` error
  serializer as workflow failures. Switched async-coroutine handling
  from deprecated `asyncio.get_event_loop().run_until_complete(...)` to
  `asyncio.run(...)` (the recommended primitive on 3.10+).

- bun_executor.rs: when copying preprocessed args into
  `checkpoint.input_args`, surface JSON parse failures via `?` instead
  of silently coercing to `Value::Null` (which would persist a corrupted
  arg into every child re-run). Also collapsed the redundant double
  iteration into a single pass.

- windmill-api-scripts/scripts.rs: switched the runnable-script filter
  from an allow-list (`auto_kind IS NULL OR = 'wac'`) to a deny-list
  (`<> 'lib'`), so future `auto_kind` values aren't silently filtered
  from triggers/dropdowns.

- windmill-parser-py: aligned the parser's WAC heuristic with the
  runtime detector `is_wac_v2_py` — `@task` is now optional, matching
  the runtime which says workflows that only use inline `step()` are
  still WAC. Added a regression test `test_parse_python_wac_step_only`.

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-04-27 13:38:49 +00:00
centdixandClaude Opus 4.5 abbfd504ac feat: add agents skills to cli init (#8948)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-27 12:56:47 +00:00
Diego Imbert 15bba79ef2 fix: Audit logs filters UI spacing (#8944)
* fix: Audit logs filters UI spacing

* nit
2026-04-27 12:56:28 +00:00
Ruben FiszelandClaude Opus 4.7 e8f7589d7a fix: delete instance settings cleared via bulk endpoint (#8949)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:55:12 +00:00
Ruben Fiszelandrubenfiszel 76108ed5b2 chore(main): release 1.691.1 (#8941)
* chore(main): release 1.691.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-27 04:49:19 +00:00
Ruben FiszelandClaude Opus 4.7 ddf14a51d8 refactor: split git repo viewer effects to remove redundant calls (#8943)
* refactor: split git repo viewer effects to remove redundant calls

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

* refactor: compact file preview header in s3 file picker

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

* refactor: skip empty preview status row when no message applies

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-04-27 04:46:22 +00:00
Ruben FiszelandClaude Opus 4.7 b8fcb7f04b fix: preserve s3 rootPath when reloading git repo viewer (#8942)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 04:23:19 +00:00
Ruben FiszelandClaude Opus 4.7 2f58a31d00 fix(cli): preserve case in raw-app runnable filenames (#8940)
* fix(cli): preserve case in raw-app runnable filenames

The path assigner lowercased filesystem-safe names, so a raw-app
runnable id like CamelCaseTSRunnable produced a YAML metadata file
keeping the original case but a code file lowercased to
camelcasetsrunnable.ts. On the next push, loadRunnablesFromBackend
paired the two by case-sensitive name match, failed, and registered
the lowercase code file as a separate empty runnable — surfacing as
duplicate runnables in the app editor.

Stop lowercasing in sanitizeForFilesystem and dedupe path assigners
case-insensitively so case-only collisions still get a counter on
case-insensitive filesystems. Also match content/lock files and the
processed-id set case-insensitively in loadRunnablesFromBackend so
repos already pulled by the buggy CLI can recover on the next push
without re-pulling.

Fixes #8939

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

* refactor(cli): extract readSiblingLock helper, drop test #ref comments

Address review:
- Both call sites in loadRunnablesFromBackend now use the same
  case-insensitive lock-file lookup, so a partial-legacy repo with a
  mixed-case code file and lowercase lock (or vice versa) works in the
  orphan-code branch too, not just the YAML branch.
- Remove `Regression for #8939 …` comments from the new tests; CLAUDE.md
  bans referencing the current task/issue from source comments. Test
  names already describe the behavior under test.

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

* test(cli): update assigner-path assertions for case preservation

Three tests in inline_scripts_failure_preprocessor_unit.test.ts asserted
that the assigner produced lowercased filenames (get_users_data, step_b)
from mixed-case summaries — that was the buggy lowercasing behavior.
With case preserved end-to-end, the assigner now returns Get_Users_Data
and Step_B.

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-04-27 04:02:39 +00:00
Ruben Fiszelandrubenfiszel 612a39bcfc chore(main): release 1.691.0 (#8931)
* chore(main): release 1.691.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-24 19:49:44 +00:00
centdixandClaude Opus 4.5 483fb1fb9a perf: reduce app ai chat token usage (#8928)
* test: add app chat token usage evals

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

* perf: make app file listing metadata only

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

* perf: reduce app datatable prompt context

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

* test: add app datatable persistence eval

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

* test: fix file manager rename app eval

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

* test: remove selected app context eval cases

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

* fix: address app eval review feedback

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-24 19:49:16 +00:00
Ruben FiszelandClaude Opus 4.7 e732004180 fix(nativets): forward OTEL-prefixed console logs to tracing events (#8937)
* fix(nativets): forward OTEL-prefixed console logs to tracing events

Nativets jobs run in-process and bypass the handle_child.rs stdout loop
where `OTEL: ` lines are turned into `tracing::event!` calls when
`OTEL_JOB_LOGS=true`. Apply the same prefix handling in the nativets
log receiver so `console.log("OTEL: ...")` reaches the OTEL exporter
like it does for other runtimes.

Moves `OTEL_JOB_LOGS` and `OTEL_PREFIX` into windmill-common so both
crates share the same definition.

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

* fix(nativets): emit job_log tracing target so logs reach OTEL bridge

For non-native runtimes, `lines_to_stream` → `process_streaming_log_lines`
(EE) emits every stdout line as `tracing::info!(target: "windmill:job_log", ...)`,
which is picked up by the EE `LogContextBridge` and exported to OTEL
(the bridge's filter is `EnvFilter` only, not the targets filter that
drops `windmill:job_log` from stdout/file sinks).

Nativets delivers logs in-process via a channel, so it never goes
through that path and console.log output only reached the Windmill UI.
Emit the same `windmill:job_log` event per line from the nativets log
receiver.

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-04-24 19:42:05 +00:00
Ruben Fiszel 749aff024f sqlx 2026-04-24 18:47:26 +00:00
489337d533 feat: cli diff/deploy no-op handling + promotion debouncing (#8936)
* feat: cli diff & deploy no-op handling + promotion debouncing

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

* chore: bump ee-repo-ref

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

* chore: update ee-repo-ref to ed842061576c3ac9b9eb89bb87f6db5b67904474

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

Previous ee-repo-ref: 1210d9f63de8eea4c3a210a10c60fe6382df477b

New ee-repo-ref: ed842061576c3ac9b9eb89bb87f6db5b67904474

Automated by sync-ee-ref workflow.

* test(git-sync): e2e tests for promotion-mode debounce keys

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-04-24 18:12:10 +00:00
95d4c6a94d feat(cli): non-interactive Slack connect/disconnect + sync round-trip fixes (#8935)
* feat(cli): non-interactive Slack connect/disconnect

Extract create_slack_workspace_artifacts / create_slack_instance_artifacts
from the browser OAuth callbacks and expose them via two new endpoints that
accept a pre-minted xoxb bot token:

- POST /w/{workspace}/workspaces/connect_slack (admin)
- POST /oauth/connect_slack_instance (super-admin)

Both produce bit-for-bit identical DB state to the UI browser flow.

Wire three CLI commands as thin wrappers:
- wmill workspace connect-slack
- wmill workspace disconnect-slack
- wmill instance connect-slack

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

* fix(cli): round-trip stability for workspace settings handlers

wmill sync push was destroying UI-configured error_handler/success_handler
state on every deploy. Two orthogonal bugs:

(a) pushWorkspaceSettings called editErrorHandler with `path: undefined`
    when the YAML lacked the handler block, which the backend treats as a
    clear — so syncing settings.yaml that didn't mention the handler wiped
    the DB row. Fix: skip the call entirely when absent from YAML.

(b) edit_error_handler omitted muted_on_cancel / muted_on_user_path when
    false, but the CLI always sends them, causing perpetual deepEqual
    drift and a spurious editErrorHandler call on every sync push. Fix:
    always persist both booleans.

migrateToGroupedFormat now preserves explicit `null` on
error_handler / success_handler as a "clear remote" signal distinct from
absence. Widen ErrorHandlerConfig | null / SuccessHandlerConfig | null to
make this explicit in the type.

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

* feat(cli): sync support for workspace-level Slack OAuth override

Add slack_oauth_client_id and slack_oauth_client_secret to the v2 tarball
export and to pushWorkspaceSettings, so the workspace-level OAuth override
is now fully managed as code through settings.yaml.

Semantics:
  - both defined and truthy → setWorkspaceSlackOauthConfig (upsert)
  - both defined but falsy (e.g. empty strings) and remote has a value
    → deleteWorkspaceSlackOauthConfig
  - either omitted → leave remote alone ("not managed by git")

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

* refactor(cli): normalize workspace settings sync to "omit = clear"

Earlier commits on this branch introduced an "omit = keep" rule for
error_handler / success_handler / slack_oauth_client_{id,secret} that
diverged from every other workspace setting (webhook, deploy_to, etc. all
treat YAML as canonical: absence = clear). Normalize:

- v2 tarball always emits these 4 fields (null when remote is NULL) so
  round-trip is bijective and settings.yaml is a complete snapshot.
- pushWorkspaceSettings drops the absent-from-YAML guards; YAML is
  canonical. Absence and explicit null both clear the remote — same rule
  as every other field.
- set_slack_oauth_config / delete_slack_oauth_config now fire
  handle_deployment_metadata so UI mutations reach git-sync-enabled
  workspaces' committed settings.yaml.

Policy for users: pull before push (same as every other setting). On first
post-upgrade pull, explicit `null` keys appear for any workspace whose
handlers / oauth override are unset — one-time YAML diff, no semantic
change.

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

* test(cli): add unit + integration coverage for Slack settings sync

Unit tests (settings_unit.test.ts): cover migrateToGroupedFormat preserving
explicit `null` on error_handler / success_handler, and passthrough of
slack_oauth_client_id / _secret (both populated and null values).

Integration tests (slack_settings_sync.test.ts, skipped on CI per the same
convention as datatable_settings_sync.test.ts): exercise the full backend
via withTestBackend to verify

  1. pull emits null for unset error_handler / success_handler /
     slack_oauth_client_id / _secret;
  2. round-trip with all-null handlers is idempotent;
  3. push of populated slack_oauth_config upserts;
  4. omitting the slack_oauth keys from YAML clears remote (universal
     "omit = clear" rule);
  5. explicit null error_handler in YAML clears remote;
  6. round-trip preserves a populated error_handler exactly, including the
     always-persisted muted_on_cancel / muted_on_user_path booleans.

Also feature-gates `use crate::oauth2_oss::workspace_connect_slack` and its
route registration behind `cfg(feature = "oauth2")`: the import caused a
build failure on subsets of the workspace without the oauth2 feature,
surfaced by the integration test harness.

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

* chore: bump ee-repo-ref to 59b6123

Pins windmill-ee-private to the tip of branch alp/slack_cli, which
contains the companion EE changes (helper extraction, non-interactive
Slack connect handlers, git-sync for Slack settings mutations).

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

* Update SQLx metadata

* chore: regenerate system prompts for new slack CLI commands

Captures the new workspace connect-slack, workspace disconnect-slack,
and instance connect-slack commands in the auto-generated files that
CI enforces via system_prompts/check-freshness.sh.

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

* chore: update ee-repo-ref to b4a5ca11e3b96ff03793c2bd396dbc1fe6ea1022

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

Previous ee-repo-ref: d7e44d0519327ec9077625130365e887826f324b

New ee-repo-ref: b4a5ca11e3b96ff03793c2bd396dbc1fe6ea1022

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>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-04-24 17:14:08 +00:00
a1a73309fd refactor: remove force_branch from git sync settings (#8934)
* [ee] refactor: remove force_branch from git sync settings

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

* chore: update ee-repo-ref to 680885a4e8c8de5185650cddeb56b926e722718f

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

Previous ee-repo-ref: 37fe2e1286a162119df885062e50461400631850

New ee-repo-ref: 680885a4e8c8de5185650cddeb56b926e722718f

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-24 16:19:41 +00:00
8a986500b9 feat: WM_TESTED_RUNNABLE env var + wildcards in test: annotation (#8926)
* feat: WM_TESTED_RUNNABLE env var + wildcards in test: annotation

Extends the CI test feature so a single test script can cover multiple
runnables and branch on which one triggered it.

- test: annotation now supports glob wildcards: `*` matches one path
  segment, `**` matches any depth. A new `ci_test_path_matches` helper
  in windmill-common compiles patterns to anchored regexes with a small
  quick_cache LRU.
- New migration adds a Postgres GENERATED `has_wildcard` column + partial
  index on ci_test_reference so exact-match lookups keep using the
  primary index and only wildcard rows are scanned for regex matching.
- ci_test trigger query and the UI `ci_test_results` / `ci_test_results_batch`
  endpoints split into exact + wildcard paths; the batch endpoint now
  issues one query per distinct kind instead of one per item.
- Worker injects `WM_TESTED_RUNNABLE={kind}/{path}` into CI test jobs,
  derived from the trigger metadata stored at push time.

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

* fix: scope CI test job lookup by trigger + populate WM_TESTED_RUNNABLE in resource interpolation

Scope the ci_test_results LATERAL lookup by v2_job.trigger so multi-target
tests (via wildcards or multiple exact annotations) report the correct job
per target. Also pass the tested runnable through transform_json_value in
resources.rs for consistency with schedule_path.

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

* chore: update ee-repo-ref to 489eb0d89702e5d1cc7c6e0f9ba9e0c8e5063741

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

Previous ee-repo-ref: e7534bcafcd8c27fcf870b2ea868e901b00b7960

New ee-repo-ref: 489eb0d89702e5d1cc7c6e0f9ba9e0c8e5063741

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-04-24 12:52:25 +00:00
Ruben Fiszel d60adac494 ref pointer 2026-04-24 04:57:22 +00:00
Ruben Fiszel d7d79dfbee sqlx 2026-04-24 04:39:43 +00:00
73fab0c264 fix(autoscaling): native worker stuck at max + wrong TimeAgo (#8930)
* fix(autoscaling): return applied_at with UTC timezone in events API

autoscaling_event.applied_at is a naive TIMESTAMP column. Serializing as
NaiveDateTime produces an ISO string with no timezone, which the browser
parses as local time — for users west of UTC this lands in the future and
TimeAgo's Math.max(0, …) clamps every event to "0s ago".

Cast the column with AT TIME ZONE 'UTC' and type the field as DateTime<Utc>
so the response includes a Z suffix.

Also pulls in the EE count-distinct fix for native worker autoscaling.

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

* chore: update ee-repo-ref to 4128203739a973330599dacfb054203cf9832f3a

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

Previous ee-repo-ref: 32636bc3e3996101554d5ef504785346929a593b

New ee-repo-ref: 4128203739a973330599dacfb054203cf9832f3a

Automated by sync-ee-ref workflow.

* chore: bump ee-repo-ref for applied_at UTC insert 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>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-24 04:35:12 +00:00
4cf53a44bb feat: add auto-login SSO provider instance setting (#8929)
* [ee] feat: add auto-login SSO provider instance setting

Adds an instance-level `auto_login_provider` setting that, when set to
an OAuth provider key (e.g. "okta") or "saml", causes the login page
to auto-redirect users to the configured SSO flow on mount.

Useful for orgs with a single SSO where the provider button grid adds
a pointless extra click.

- Backend: new global setting constant, read from DB in list_logins
  handler and returned as the `auto_login` field in the response
- Frontend: Login.svelte auto-redirects in loadLogins() when the
  configured provider is actually present in the response
- Escape hatch: `?no_sso=1` skips the auto-redirect and shows the
  normal login form (admin fallback when SSO is broken)
- No redirect loop: if the `error` prop is set (SSO callback failed),
  the redirect is skipped
- Admin UI: new text field under Auth/OAuth/SAML in instance settings

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

* fix: skip auto-redirect on /user/login page

Auto-redirect should only fire on embeds where the user did not
explicitly navigate to a login screen (public app popup, approval
pages). Visiting /user/login is an explicit sign-in action — often by
an admin who needs password fallback — so we must never hijack it.

Gate the logic on a new `autoRedirect` prop (default true). The main
login page passes `autoRedirect={false}`.

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

* chore: update ee-repo-ref to b7157d55fb9f8d8f7aeb7b1fb69bc935af895a2f

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

Previous ee-repo-ref: e32a48499d206a24e0c12817b465775321b0ee41

New ee-repo-ref: b7157d55fb9f8d8f7aeb7b1fb69bc935af895a2f

Automated by sync-ee-ref workflow.

* fix: handle popup-blocked auto-redirect in popup mode

When Login is embedded with popup=true (public app), auto-redirect
funnels through window.open() without a user gesture — browsers block
it by default, leaving the user stuck on "Signing you in…".

Detect window.open returning null, clean up listeners, reset
autoRedirecting so the provider button grid re-renders, and surface a
toast pointing users at the manual button. The grid click retains its
user gesture and passes the popup blocker.

Also extracts a redirectSaml() helper so the SAML auto-redirect path
and the SSO button click share the same logic.

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-04-24 04:20:01 +00:00
Ruben Fiszelandrubenfiszel 161ec8d722 chore(main): release 1.690.0 (#8921)
* chore(main): release 1.690.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-23 14:33:53 -04:00
f429cb5e48 feat: add OTEL_HOST_NAME env override for host.name attribute (#8923)
* feat: add OTEL_HOST_NAME env override for host.name attribute

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

* chore: update ee-repo-ref to f6bc5647cc41ce348111f781b4a0db2153a28f8a

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

Previous ee-repo-ref: c92441ae0d6d8e89b48677db8cb6b78e5bdae2db

New ee-repo-ref: f6bc5647cc41ce348111f781b4a0db2153a28f8a

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>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-04-23 18:26:31 +00:00
Ruben FiszelandClaude Opus 4.5 664d0f838d fix: ensure schema is inferred on script/flow module load (#8927)
* fix: ensure schema is inferred on script/flow module load

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

* fix: memoize all WASM parser init promises

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-23 16:46:24 +00:00
hugocasaandClaude Opus 4.7 1722a7a2af fix(cli): use wmill.yaml key consistently for workspace-specific items (#8900)
* fix(cli): use wmill.yaml key (not branch name) for workspace-specific filenames

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

* fix(cli): resolve --workspace as config key even with --base-url

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

* test(integration): assert workspace config key drives filename suffix in git-sync

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

* Revert "test(integration): assert workspace config key drives filename suffix in git-sync"

This reverts commit 528993fe29.

* fix(cli): filter reserved keys and preserve validation-skip on non-config --workspace

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-04-23 16:31:41 +00:00
d6c642b170 feat: add Azure Event Grid triggers (#8888)
* feat: add Azure Event Grid triggers (EE)

Introduces a new enterprise trigger kind `azure` that supports three
modes via a single unified trigger type:
- basic_push: Azure Event Grid basic — custom topics, system topics
  (Storage, Resource Manager, Key Vault, etc.), domains (push only)
- namespace_push: Event Grid Namespace topics (CloudEvents over HTTP push)
- namespace_pull: Event Grid Namespace topics (HTTP pull with lock-token
  ack/reject for dead-lettering)

Auth uses a Service Principal resource (tenant_id, client_id,
client_secret, subscription_id). Subscriptions are created in
CloudEvents 1.0 schema so the push webhook handler and the pull listener
share one payload parser.

Backend
- New crate `windmill-trigger-azure` (OSS stubs + EE impl symlinked from
  windmill-ee-private)
- Migration `azure_trigger` table with CHECK constraints enforcing
  mode/columns coherence
- `TriggerKind::Azure`, `JobTriggerKind::Azure`,
  `DeployedObject::AzureTrigger` variants
- Push route `/api/azure/w/{workspace}/*path` handles classic
  Event Grid SubscriptionValidation handshake and CloudEvents 1.0
  abuse-protection OPTIONS handshake
- Optional inbound JWT validation (audience check only for v1)
- Feature flag `azure_trigger` propagated through windmill-api,
  windmill-store (resource helper), and added to ee_core

Frontend
- `triggers/azure/` editor with mode toggle (basic/namespace-push/
  namespace-pull) and per-mode config (topic ARM id / namespace +
  topic name / subscription / filters / push auth / pull options)
- Registered in icon map, display names, save functions, badge,
  wrapper, editor, add-trigger menu

OpenAPI
- `AzureTrigger`, `AzureTriggerData`, `AzureMode`,
  `AzureSubscriptionMode`, `AzureDeliveryConfig`, `TestAzureConnection`
  schemas; `/azure_triggers/*` endpoints; client regenerated

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

* chore: update ee-repo-ref to eaa7c3a9cb37a9ccc93f10a2535d929365acd2d8

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

Previous ee-repo-ref: 9689014e8c12c36c1059fd8fa5758d550b8b8bc9

New ee-repo-ref: eaa7c3a9cb37a9ccc93f10a2535d929365acd2d8

Automated by sync-ee-ref workflow.

* feat(azure-trigger): secret-auth push, ARM discovery, capture isolation, CLI + parity

Frontend:
- Split mode selector into Namespace/Basic + Pull/Push
- ARM resource dropdowns (namespaces, Basic topics, namespace topics)
  populated from the service principal; cascade with stale-selection
  reset on SP / edition change
- Remove stale authenticate toggle + audience input (server-managed
  push_auth_config has replaced them)
- Azure listing page: "Create from template" button; "Also delete Azure
  subscription" toggle in the delete modal; simplified trigger label
  falling back to path
- AzureCapture.svelte: "Test subscription name" with -wm-capture suffix
- CompareWorkspaces.svelte: wire Azure for fork/compare
- Drop Trigger-deployed/event-loss warning (capture subscription is
  isolated with -wm-capture)

Backend:
- Shared-secret push auth (see EE crate for detail)
- JSONB push_auth_config column (renamed from delivery_config), #[serde(skip)]
  so clients/CLI/exports never see it
- Drop redundant enabled column; mode supersedes
- Azure capture infra: AzureTriggerConfig + set_azure_trigger_config +
  azure_payload route + TriggerKind::Azure arm; PT15M queue TTL on
  capture subscriptions so they bound storage after tab close
- Granular ACLs, users offboarding, trash, git-sync deployed-object:
  all include azure_trigger

CLI:
- Add azure to TRIGGER_TYPES, pushObj dispatch, getTypeStrFromPath,
  trigger commands (get/update/create/list/template), sync delete
  switch + regex; e2e test for `trigger new --kind azure`
- system_prompts: SCHEMA_MAPPINGS + schema_names include AzureTrigger;
  auto-generated/* regenerated

Skill:
- .claude/skills/adding-a-trigger/ checklist covering every file that
  needs editing when wiring a new trigger type (learned from this PR)

ee-repo-ref bumped to b0e490cbf3724b7b64c6a5b010e3bdf24acd873c.

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

* fix(azure-trigger): ci — ShareModal Kind + regenerated system_prompts

- frontend/src/lib/components/ShareModal.svelte: add 'azure_trigger'
  to the Kind type so the listing page's "Permissions" action compiles
  (ts2345 — caught by npm_check on CI, missed by fast-check locally).
- system_prompts/auto-generated/: regenerate to drop the stale
  delivery_config / AzureDeliveryConfig fields from the Azure schema
  (check-freshness on CI).

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

* refactor(azure-trigger): use workspace constant_time_eq crate

Drop hand-rolled constant-time compare in favour of the workspace
constant_time_eq crate (same one used by http_trigger_auth).

ee-repo-ref bumped to 9659382d47286e7f7f66d01b6f5dd8d4ed34848b.

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

* fix(azure-trigger): pass placeholder + disabled via inputProps

`TextInput`'s `placeholder` and `disabled` go through its `inputProps`
prop — CI's `npm run check` caught the stale top-level passing that
`npm run check:fast` missed. Align with the DefaultEmailConfigSection
pattern.

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

* fix(azure-trigger): correct LATEST_GIT_SYNC_SCRIPT_PATH version to 28213

The hub deploy of the azure-aware sync-script is version 28213, not
28214. Backend was pinning a non-existent hub script, which broke the
git_sync_e2e suite (every deploy's sync step 404'd).

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

* fix(azure-trigger): add azure_triggers to token scope selector + skill

- windmill-api/src/token.rs: `build_trigger_scope_domains` was missing
  `("azure_triggers", "Azure Event Grid")`, so the CreateToken UI's scope
  selector didn't surface azure_triggers:read/write. Backend already had
  `ScopeDomain::AzureTriggers` wired (scopes.rs), this just exposes it.
- .claude/skills/adding-a-trigger/SKILL.md: capture both scope-related
  files under the hardcoded-arrays section so future triggers don't miss
  the UI surface.

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

* docs(adding-a-trigger-skill): clarify token.rs scope effect

Not a regression — nothing was working before. Skipping TRIGGER_DOMAINS
just means the scope works via API/CLI but has no UI checkbox.

* docs(adding-a-trigger-skill): trim token.rs bullet

* fix(azure-trigger): regen openapi-deref + swap textarea for TextInput

- Run build_openapi.sh to regenerate openapi-deref.{yaml,json} with the
  12 azure_triggers paths + schemas. These files are served by the
  runtime (include_str! in windmill-api/src/lib.rs) to external SDK
  consumers; without this regen the new endpoints wouldn't be advertised.
- Replace the raw <textarea> for event type filters with the
  design-system TextInput in textarea mode (frontend/CLAUDE.md bans raw
  HTML elements).

Addresses cubic + claude PR review items.

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-04-23 16:30:18 +00:00
7fa924e67e fix: correct flow conversation pagination (#8919)
* fix: remove conversation after_id filter

* fix: implement message after_id cursor

* fix: use persisted cursor for chat polling

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

* refactor: simplify message cursor ordering

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

* refactor: use cte for message cursor

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

* Update SQLx metadata

* fix: use monotonic flow message cursor

* Update SQLx metadata

* fix: tighten flow message cursor pagination

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

* Update SQLx metadata

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-23 16:29:29 +00:00
centdixandClaude Opus 4.5 132d8a61f9 fix: slim app ai chat context (#8922)
* fix: slim app ai chat context

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

* fix: remove stale app chat selection UI

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

* refactor: trim app chat selected context

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

* test: trim app chat context coverage

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

* test: remove app tool assertion

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-23 16:21:58 +00:00
centdixandClaude Opus 4.5 07951e81ae fix: include endpoint descriptions in mcp tools (#8925)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-23 16:21:15 +00:00
Ruben Fiszel 2ed26c2254 rust client nit 2026-04-23 16:16:10 +00:00
Ruben FiszelandClaude Opus 4.7 dac29e7d23 fix: load job metadata on approval page via approval token (#8924)
* fix: load job metadata on approval page via approval token

The approval page polled getJob without auth, which 400s for non-anonymous
jobs. The page swallowed the error so approvers saw the form but no flow
args, metadata, or graph. Accept the existing approval token on getJob and
skip the non-anon-user check when it validates against the job's flow.

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

* fix(approval): address review feedback

- Validate approval token against URL job id directly before resolving
  the parent flow, saving a DB roundtrip on the happy path (approval URLs
  always carry the flow id).
- Request getJob with no_code/no_logs from the approval page so a
  token-bearer only sees what the UI renders (args, raw_flow, metadata).
- Tighten OpenAPI description for the approval_token query param.

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-04-23 15:36:56 +00:00
centdixandClaude Opus 4.5 9a60ff2e77 feat: add ai agent conversation output control (#8915)
* feat: add ai agent chat output flag

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

* fix: suppress ai agent tool chat messages

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

* refactor: rename ai agent conversation output flag

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

* feat: expose ai agent conversation output toggle

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

* fix: gate ai agent chat tab by chat mode

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

* chore: regenerate system prompts

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

* fix: address ai agent chat review feedback

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-23 14:13:20 +00:00
Ruben Fiszelandrubenfiszel 6abe33109a chore(main): release 1.689.0 (#8894)
* chore(main): release 1.689.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-22 20:06:31 +00:00
Ruben FiszelandClaude Opus 4.7 e73770c247 fix: derive debug signing key deterministically from JWT_SECRET (#8917)
Previously each API replica generated a random Ed25519 signing key at
startup (unless DEBUG_SIGNING_KEY_SEED was set). In multi-replica
deployments this caused "Invalid JWT signature" rejections in the
multiplayer server: the browser could sign a token on pod A while
`windmill-extra` had cached the JWKS public key from pod B.

Derive the seed deterministically from the DB-backed JWT_SECRET using
SHA-256 with a domain-separation tag so all pods agree without
coordination. Re-derive on JWT_SECRET rotation. The
DEBUG_SIGNING_KEY_SEED env var is still honored as an override.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:05:08 +00:00
Ruben FiszelandClaude Opus 4.7 bbb564c142 fix: omit default_permissioned_as from tarball export when empty
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:31:37 +00:00
Ruben FiszelandClaude Opus 4.7 18eed92dd6 fix: add aws-config to private feature to restore ce build
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:31:34 +00:00
centdixandClaude Opus 4.5 eeb5d12be3 fix: support windmill chat answer override (#8909)
* fix: support windmill chat answer override

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

* fix: remove output fallback from chat override

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

* fix: handle non-string chat answer overrides

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-22 17:27:10 +00:00
GuilhemandClaude Opus 4.7 131dd0682f restore bottom padding on script run page (#8916)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:37:16 +00:00
Ruben FiszelandClaude Opus 4.7 4e6e7a0407 refactor(cli): extract fileset parent push helper (#8914)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:36:25 +00:00
centdixandClaude Opus 4.5 e98bdfd5c1 add plugin skill refresh flag (#8913)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-22 15:58:26 +00:00
Ruben FiszelandClaude Opus 4.5 99bc96d0b2 feat: auto-strip UTF-8 BOM when reading local files in CLI (#8911)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-22 15:50:47 +00:00
Ruben FiszelandClaude Opus 4.5 dc896737ac fix: apply powershell workspace dependencies to deployed scripts (#8912)
* fix: persist powershell workspace deps in deployed script lock

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

* fix: trigger dep job for powershell scripts on deploy

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-22 15:44:00 +00:00
Ruben FiszelandClaude Opus 4.7 f29badcf36 fix: push parent resource on fileset child add/delete (#8910)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:50:14 +00:00
GuilhemandClaude Opus 4.7 932d183311 fix: persist flow groups from AI chat tool calls (#8906)
* fix: persist flow groups from AI chat tool calls

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

* fix: validate group ids and coerce empty groups to undefined

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-04-22 14:46:40 +00:00
hugocasa dffb89e006 fix: trigger failure_module when branchone predicate throws (#8905)
A BranchOne predicate expression that threw was propagated as a
flow-level error, bypassing the flow's failure_module — especially
silent when nested inside a forloop with skip_failures: true, where
the failed iteration was swallowed with no handler ever invoked.

Catch the predicate-eval error inside compute_next_flow_transform's
BranchOne case, return a new NextFlowTransform::StepFailure variant,
and have push_next_flow_job route it through
update_flow_status_after_job_completion with success=false. Predicate
errors now behave exactly like a failing script step: failure_module
runs when defined, skip_failures still skips, workspace error handler
fires when the flow fails.

Closes #8889
2026-04-22 14:46:21 +00:00
centdixandClaude Opus 4.5 aea74445a3 fix: add flow conversation token scope (#8903)
* fix: add flow conversation token scope

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

* refactor: make flow conversations scope plural

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

* fix: update flow chat service import

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-22 13:42:22 +00:00
centdixandClaude Opus 4.5 1e83278fe2 fix: skip opus 4.7 sampling params (#8904)
* fix: skip opus 4.7 sampling params

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

* fix: keep opus 4.7 handling frontend side

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

* fix: generalize opus 4.7 model matching

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

* fix: stop normalizing opus 4.7 thinking

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-22 13:11:06 +00:00
centdixandClaude Opus 4.6 26a6d1e4ce refactor: create windmill-ai crate (part 1 — types, traits, base modules) (#8530)
* refactor: create windmill-ai crate and move base AI types from windmill-common

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

* refactor: move worker AI types to windmill-ai crate

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

* refactor: move QueryBuilder trait and StreamEventSink abstraction to windmill-ai

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

* fix: add base64 dependency to windmill-ai for bedrock PDF support

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

* docs: add windmill-ai refactor plan

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

* refactor: address PR review — remove dead bedrock feature, add boxed_sink helper, move plan to docs

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 11:20:27 +00:00
Ruben FiszelandClaude Opus 4.7 05baa4ab02 feat: allow hiding catalog picker and raw input on s3 form fields (#8902)
* feat: allow hiding catalog picker and raw input on s3 form fields

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

* refactor: type itemsType.resourceType instead of casting to any

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-04-21 21:07:48 +00:00
Ruben FiszelandClaude Opus 4.7 680c711f92 fix: detect and clearly label OOM in zombie flow alerts (#8901)
* fix: detect and clearly label OOM in zombie flow alerts

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

* fix: address review feedback on zombie flow OOM detection

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-04-21 11:14:15 -07:00
hugocasaandClaude Opus 4.7 53badf1a8c fix: track dollar-quoted strings in SQL block splitter (#8891)
* fix: track dollar-quoted strings in SQL block splitter

Queries like `CREATE FUNCTION ... AS $$ ... ; ... $$ LANGUAGE plpgsql;`
were being shredded on every `;` inside the function body because the
SQL splitter's state machine didn't recognize PostgreSQL dollar-quoted
strings. Add an `InDollarQuote(tag)` state so `$$ ... $$` and
`$tag$ ... $tag$` regions are treated as a single quoted span.

Opt-in via a new `track_dollar_quotes` flag on `parse_sql_blocks`;
enabled for PostgreSQL and DuckDB, disabled for MySQL/Oracle/BigQuery/
Snowflake which don't support the syntax.

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

* fix: make windmill-parser-wasm a self-contained workspace

The wasm parser crate is excluded from the backend workspace (its
nightly-only `cargo-features = ["panic-immediate-abort"]` would break
stable cargo on the whole workspace), but its manifest still used
`.workspace = true` inheritance — which fails with "failed to find a
workspace root" once the parent no longer considers it a member.

Declare the crate as its own workspace by adding `[workspace]`,
`[workspace.package]`, and `[workspace.dependencies]` tables. Mirror
the relevant entries from the parent `backend/Cargo.toml` (same
version specs, same path targets) so resolution stays byte-identical
to what the parent would have produced.

Also:
- Teach `.github/change-versions.sh` (+ mac variant) to update this
  crate's own `Cargo.toml` version and bulk-bump the `windmill-*`
  entries in its `Cargo.lock` on each release.
- Bump the frontend's pinned `windmill-parser-wasm-regex` to 1.688.0
  to match the freshly-built package, and refresh `package-lock.json`.
- Regenerate the wasm crate's `Cargo.lock` from scratch (first build
  under the new workspace re-resolves the full graph; target-gated
  deps from sibling crates like `windmill-parser-py-imports` are
  now recorded in the lockfile but not compiled when targeting
  wasm32).

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-04-21 17:07:01 +00:00
hugocasaandClaude Opus 4.7 2d4fadb590 feat: add s3 stream progress logs to other DB executors (#8898)
Extract the MSSQL s3 ingest+upload logging pattern into a reusable
`s3_stream_and_upload_with_logs` helper and apply it to the PostgreSQL,
MySQL, OracleDB, BigQuery, and Snowflake executors. Each s3 streamed
query now emits periodic progress lines, an ingest-done line, and an
upload+transcode-done line to the job output, matching MSSQL.

`convert_json_line_stream` now returns `BoxStream<'static, _>` so the
output stream can be forwarded to `s3.upload` from inside the generic
helper without lifetime gymnastics; the two existing callers already
boxed the result, so behavior is unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:06:42 +00:00
centdixandClaude Opus 4.5 434113b5fd tests: add cli eval behavior checks (#8899)
* feat: add cli eval behavior checks

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

* fix: harden cli eval command parsing

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-21 16:17:40 +00:00
centdixandClaude Opus 4.5 fddd8e288f fix: add proxy eval coverage for gemini schemas (#8897)
* feat: add proxy transport for ai evals

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

* fix: strip propertyNames for gemini schemas

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

* fix: require explicit eval transport

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-21 16:03:44 +00:00
Ruben FiszelandClaude Opus 4.7 aaf3a19747 feat: async dep endpoints and queue-position logs in cli (#8895)
* feat: async dep endpoints and queue-position logs in cli

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

* refactor: share logQueueStatus between dev.ts and job_polling.ts

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

* fix: continue polling on transient errors in job_polling.ts

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-04-21 15:06:46 +00:00
centdixandClaude Opus 4.5 21ab6f1dd7 chore: add codex svelte mcp config (#8892)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-21 14:57:23 +02:00
hugocasaandClaude Opus 4.7 f8c916cb60 fix: rust nsjail RUSTUP_HOME mount and arch-aware cache keys (#8890)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 05:21:22 -04:00
Ruben Fiszelandrubenfiszel 18b1bf8f58 chore(main): release 1.688.0 (#8881)
* chore(main): release 1.688.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-20 20:37:37 +00:00
10d1a932d5 fix: batch cancel dropping jobs from other workspaces (#8887)
* fix: batch cancel silently dropping jobs from other workspaces

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

* fix: gate cross-workspace batch cancel on all_workspaces flag

Enforce path workspace unless all_workspaces=true, so cross-workspace
selections only succeed when the UI is actually in all-workspaces mode.

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

* sqlx

* fix: gate cancel_selection all_workspaces on admins workspace

Matches the convention used by count_queue_jobs and count_completed_jobs_detail
in the same file — cross-workspace scope is only honored when the path
workspace is "admins", preventing clients in regular workspaces from dropping
workspace scoping by passing all_workspaces=true.

Co-authored-by: Ruben Fiszel <rubenfiszel@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: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
2026-04-20 20:00:34 +00:00
Ruben FiszelandClaude Opus 4.7 7dde9824b0 toast and tag key mismatch in autoscaling test button (#8886)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:06:06 +00:00
hugocasaandClaude Opus 4.7 43a6b57581 perf: speed up mssql s3 ingest and add phase logs to job output (#8884)
* perf: speed up mssql s3 ingest and add phase logs to job output

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

* chore: drop mssql s3 progress interval to 10s for better visibility

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

* chore: drop transient tests that compared against removed code path

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-04-20 18:20:02 +00:00
Ruben FiszelandClaude Opus 4.5 94f27af838 fix: log boolean predicate eval errors to root flow logs (#8885)
* fix: log boolean predicate eval errors to root flow logs

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

* refactor: use if-let over match for predicate error logging

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 18:11:07 +00:00
Avasis AIandAbhay 88469e0be4 handle empty parallelism value to prevent null error (#8878)
Clearing the parallelism input field now sets parallelism to undefined
instead of creating an object with an empty value, preventing the
'parallelism value is null' error on execution.

Closes #7864

Assisted-by: GLM 5.1

Co-authored-by: Abhay <abhayjnayakpro@gmail.com>
2026-04-20 17:51:19 +00:00
centdixandClaude Opus 4.5 a5363ea4ed refactor: unify flow chat tree operations (#8862)
* refactor: make flow chat code edits explicit

* refactor: centralize flow tree lookups

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

* refactor: simplify flow chat tree mutations

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

* refactor: reuse flow tree lookup in schema map

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

* refactor: remove flow lookup alias

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

* refactor: reuse flow tree in previous results

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

* refactor: reuse canonical flow module lookup

* fix: align rebased flow helpers

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

* docs: remove flow chat cleanup plan

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

* refactor: remove flow chat helper wrappers

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

* fix: preserve non-flowmodule AI agent tools in skeleton and previous_result

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

* refactor: consolidate flow module ID collectors into flowTree

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

* fix: search full flow tree in test_run_step to find special modules

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

* fix: recurse into aiagent tools in collectAllFlowModuleIds

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 17:50:50 +00:00
hugocasaandClaude Opus 4.7 6b859900cb docs: add job-debugging guidance to wmill init output (#8879)
* docs: add job-debugging guidance to wmill init output

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

* chore: update cli-commands skill description in generate.py source

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-04-20 17:50:11 +00:00
Ruben FiszelandClaude Opus 4.5 71c4212a90 fix: use POST for oidc token request in authed client (#8883)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 17:38:14 +00:00
Ruben FiszelandClaude Opus 4.5 12c08cc95c fix: populate wmill.d.ts schemas in wmill app dev (#8882)
* fix: populate wmill.d.ts schemas in wmill app dev

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

* feat: seed inferred schemas at wmill app dev startup

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 17:22:31 +00:00
centdixandClaude Opus 4.5 f35e10cc0a feat: add homepage connect drawer (#8880)
* feat: add homepage connect drawer

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

* fix: reset connect drawer state

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

* fix: polish home connect button

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

* fix: use standard home connect button style

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 13:07:57 +00:00
centdixandClaude Opus 4.5 46b2915a9d feat: improve app evals and localized app edits (#8863)
* chore: record app benchmark baseline

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

* feat: strengthen app benchmark persistence checks

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

* feat: seed inventory tracker benchmark case

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

* feat: add deterministic app diagnostics

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

* feat: add app chat patch_file tool

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

* test: add app session id micro-edit case

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

* fix: narrow app patch file content

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

* fix: stop gating app evals on lint

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 11:56:35 +00:00
Ruben Fiszelandrubenfiszel e063db68c9 chore(main): release 1.687.0 (#8871)
* chore(main): release 1.687.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-17 18:16:51 +00:00
Ruben FiszelandClaude Opus 4.7 0cfa131254 feat: add disable_password_login global setting (#8873)
Adds an instance-level toggle that hides the email/password form on the
login page and rejects password login, password reset request, and
password reset endpoints server-side. Useful for OAuth/SAML-only
deployments.

- New `disable_password_login` global setting + lazy_static AtomicBool
- `load_disable_password_login` loader wired into monitor initial_load
  and notify_global_setting_change listener
- Unauthenticated `GET /auth/is_password_login_disabled` endpoint so the
  login page can hide the password form when enabled
- Toggle in Instance Settings → Auth/OAuth/SAML
- Login.svelte hides the password form and the "Log in without
  third-party" toggle when the setting is on

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:12:30 +00:00
Ruben FiszelandClaude Opus 4.7 1d2d12a27d fix: default null script/flow schema to empty in bg runnable (#8872)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:13:06 +00:00
hugocasaandClaude Opus 4.6 4f998cc231 feat: add GitHub as a native trigger service (#8856)
* feat: add GitHub as a native trigger service

Add GitHub webhooks as a native trigger, allowing users to trigger
scripts/flows from repository events (push, PR, issues, etc.) via
OAuth-based webhook management.

Backend:
- DB migration adding 'github' to native_trigger_service, TRIGGER_KIND,
  and job_trigger_kind enums
- Full External trait implementation: create/update/delete/get webhooks,
  per-trigger sync verification, webhook payload preparation
- Paginated repos endpoint (up to 1000 repos)
- OAuth flow with admin:repo_hook and read:user scopes

Frontend:
- GitHub trigger form with repo picker and MultiSelect event selector
- Workspace integration settings with setup instructions
- Trigger badge, editor, and wrapper integration
- GithubIcon updated to support size/class props (matching other icons)
- Hub template reference for starter scripts

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

* fix: show GitHub in sidebar when triggers exist

Add github_used to the getUsedTriggers endpoint so the sidebar picks up
GitHub as an active trigger kind. Also document this step in the native-
trigger skill so future services don't miss it.

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

* feat: on-demand GitHub repo search instead of bulk fetch

Replace the upfront pagination through all repos with a debounced search
flow: load 30 most-recently-updated repos by default, then query GitHub's
/search/repositories API (scoped to the authenticated user via user:@me
and restricted to name matches via in:name) as the user types.

Frontend uses runed's Debounced + resource to wire the Select's filterText
to the backend query with 300ms debouncing.

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

* fix: request `repo` OAuth scope to list private GitHub repos

`admin:repo_hook` grants webhook management but not repo listing — so
/user/repos and /search/repositories returned only public repos. Switch
to `repo` (full repo scope, which is a superset and also covers webhook
management).

Users who already connected GitHub need to disconnect and reconnect to
pick up the broader scope.

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

* revert: fetch all GitHub repos upfront instead of searching on demand

Revert the debounced search flow — paginate through /user/repos (up to
1000) on form open. Simpler UX: repos are all there from the start, the
Select's built-in client-side filter handles finding one.

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

* fix: typed 404 detection + add GitHub flow template reference

Replace fragile e.to_string().contains("404") matching with a proper
http_error_status helper that downcasts through anyhow to the typed
HttpRequestError and reads the StatusCode.

Also wire the hub flow template (id 80) into NATIVE_TRIGGER_SERVICES.

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

* fix: update GitHub script template hub ID to 28202

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

* fix: align GitHub trigger with Nextcloud/Google patterns

Addresses review feedback from Claude and cubic.

Backend:
- `delete()` now only swallows NotFound (DB missing row) and 404 (API
  webhook already deleted); non-404/DB errors propagate so callers know
  cleanup failed. Matches Nextcloud's delete pattern exactly.
- `get_owner_repo_from_db` returns `Result<Option<(String, String)>>`
  instead of an error on missing row (matches Google's delete flow).

Frontend:
- `loading: boolean` (required) + `$bindable()` with no default — matches
  Nextcloud, satisfies CLAUDE.md banned-pattern rule.
- Wrap `loadRepos()` in `$effect` reacting to `$workspaceStore` so repos
  load once the store is available and refresh on workspace switch.
- Replace raw `fetch('/api/.../native_triggers/github/repos')` with the
  generated `NativeTriggerService.listGithubRepos(...)` typed client.
  Adds `/repos` route + `GithubRepoEntry` schema to openapi.yaml.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:01:55 +00:00
hugocasaandClaude Opus 4.7 ad2e855a83 fix: mint fresh Google channel IDs on update/renew (#8870)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:01:41 +00:00
Diego Imbert f655d4f295 nit: ui bug (#8869) 2026-04-17 17:14:08 +02:00
Ruben Fiszelandrubenfiszel f683c70ef4 chore(main): release 1.686.0 (#8860)
* chore(main): release 1.686.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-17 14:30:19 +00:00
e9ea06f4c2 fix: include app owner in GitHub App URL for GHE Cloud (#8846)
* fix: include app owner in GitHub App installation URL for GHE Cloud

GHE Cloud custom domains (*.ghe.com) require the owner (org/user) in
the app installation URL path: /apps/{owner}/{slug}/installations/new.
Adds an optional app_owner field to the GHES app config.

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

* chore: update ee-repo-ref.txt

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

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

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

Previous ee-repo-ref: 40dd503d8c563ff93fd2ee3fd8830a5b1c4428d2

New ee-repo-ref: 2c2b8dc99689f54b8cd916fb9472fd5698b09478

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-17 14:21:35 +00:00
Ruben FiszelandClaude Opus 4.7 1443d3562c chore: bump git-sync init repo hub script to v28201 (#8868)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:14:02 +00:00
Ruben FiszelandClaude Opus 4.7 8514347784 fix: serve populated jwks at /.well-known/jwks.json for vault (#8865)
* fix: serve populated jwks at /.well-known/jwks.json for vault

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

* fix: gate jwks route on private feature and use oidc_oss

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-04-17 14:11:09 +00:00
hugocasaandClaude Opus 4.5 172a7d16db fix: fix otel tracing on nativets
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-17 14:05:58 +00:00
Ruben FiszelandClaude Opus 4.7 f565bf7652 guard null inlineScript in app cli extractor (#8864)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:28:31 +00:00
b1a4c780dc feat: migrate slack OAuth to v2 (#8859)
* feat: [ee] migrate slack OAuth to v2

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

* fix: regenerate openapi-deref and make SlackToken.team optional

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

* chore: update ee-repo-ref to a28f3509d0aa7c0e17fa6dcb1d03d935a7a2a11c

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

Previous ee-repo-ref: d149fa6fcb90c4833bbdbd876c0466b5a6196c1c

New ee-repo-ref: a28f3509d0aa7c0e17fa6dcb1d03d935a7a2a11c

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-04-17 12:16:09 +00:00
hugocasaandClaude Opus 4.7 d99a176b6a fix: update on_behalf_of_email in app policy on offboarding (#8858)
* fix: update on_behalf_of_email in app policy on offboarding

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

* sqlx

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:11:12 +00:00
centdixandClaude Opus 4.5 51b09ace45 feat: add empty inline script warnings to flow chat (#8853)
* fix: seed empty inline flow scripts

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

* fix: cap frontend eval chat turns

* fix: roll back failed inline script seeding

* refactor: simplify inline flow script warnings

* refactor: share flow module traversal

* refactor: make flow chat code edits explicit

* fix: resolve ai tool review actions

* refactor: remove dead flow rawscript helper

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-17 12:10:26 +00:00
hugocasaandClaude Opus 4.5 0b6874fb0d feat: generate tsconfig.json during wmill init for IDE type support (#8855)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-17 12:10:06 +00:00
Ruben Fiszelandrubenfiszel 24cb414fed chore(main): release 1.685.0 (#8838)
* chore(main): release 1.685.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-16 17:54:26 +00:00
hugocasaandClaude Opus 4.6 625d23fc85 fix: make sync pull produce consistent wmill-lock.yaml hashes (#8854)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:57:11 +00:00
wendrul 0773b5bc5d fix: workspace specfic tags compatibility with forked workspaces (#8850)
* fix: workspace specfic tags compatibility with forked workspaces

* Rename _db to db and use saved WM_FORK_PREFIX

* Add ttl cache for mapping fork id to parent workspace id

* Change second option to just have a -fork suffix
2026-04-16 15:56:41 +00:00
49844eb240 fix: encourage subflow reuse in AI chat flow builder prompt (#8839)
* docs: encourage subflow reuse in AI chat flow builder prompt

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

* test: add workspace flow reuse benchmark

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: centdix <farhadg110@gmail.com>
2026-04-16 15:02:28 +00:00
centdixandClaude Opus 4.5 b39671d933 feat: add compact json patch tool to flow chat (#8840)
* fix: use compact json for flow patches

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

* test: improve flow eval harness

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

* test: record flow benchmark history

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

* fix: preserve schema in set flow json

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

* style: clean set flow json schema guard

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

* fix: clean flow patch review followups

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-16 14:31:05 +00:00
centdixandClaude Opus 4.5 b1778272fc fix: clean ai memory and cache bedrock prompts (#8847)
* fix: avoid persisting system prompts in ai memory

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

* fix: keep ai memory cleanup write-side only

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

* feat: add bedrock prompt caching for claude

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

* test: add bedrock memory regression

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

* fix: gate bedrock prompt caching by model id

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

* docs: link bedrock caching allowlist source

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-16 14:30:22 +00:00
Ruben FiszelandClaude Opus 4.6 fc49a8fed6 fix: include workspacedependencies in default git sync include_type (#8852)
* fix: include workspacedependencies in default git sync include_type

Workspace dependencies were not synced by default because the default
include_type arrays did not contain 'workspacedependencies'. This meant
users had to manually add the type to their git sync config.

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

* fix: add missing folder to new-format fallback and align all defaults

- Add 'folder' to the new-format repo fallback default include_type
  (was missing unlike the other 4 locations)
- Add 'workspacedependencies' to legacy defaultTypes fallback
- Align GitSyncFilterSettings prop default with context defaults

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:29:38 +00:00
Ruben FiszelandClaude Opus 4.6 b1aeb33ade fix: classify fileset resource files with script extensions correctly (#8851)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:17:39 +00:00
centdix 88e4120e96 add agents.md (#8849) 2026-04-16 06:26:25 -07:00
Diego Imbert 7f2486bdba fix: Update duckdb to 1.5.2 (Ducklake 1.0.0) (#8848) 2026-04-16 06:23:57 -07:00
hugocasaandClaude Opus 4.6 4bda600729 fix: skip nsjail uidmap/gidmap when DISABLE_NUSER=true (#8842)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 05:30:57 -04:00
Ruben Fiszel 0798719256 nit key check 2026-04-15 21:20:00 +00:00
362ae248fe fix: per-branch concurrency key for promotion-mode git sync (#8844)
* [ee] fix: per-branch concurrency key for promotion-mode git sync jobs

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

* chore: bump ee-repo-ref for per-branch concurrency key

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

* chore: update ee-repo-ref to 7bc0fdb8647268c7afa67b4b0bed69c897eaf92a

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

Previous ee-repo-ref: b933874649a63c5266a33360a95e3c163acc6b5f

New ee-repo-ref: 7bc0fdb8647268c7afa67b4b0bed69c897eaf92a

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-15 20:50:18 +00:00
Ruben FiszelandClaude Opus 4.5 1e266a0c0a enforce auth check on root job in get_flow_debug_info (#8843)
The root job fetch at the start of get_flow_job_debug_info was missing
.with_auth(&opt_authed), which left the anonymous-user access check
disabled for this endpoint. Child jobs in the same handler already had
the check. Unauthenticated callers holding a flow job UUID could fetch
job arguments of non-anonymous jobs, including secrets passed as flow
inputs.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 19:45:28 +00:00
centdixandClaude Opus 4.5 d3cb0c6220 fix: improve flow chat and benchmark coverage (#8825)
* fix: support special flow modules in evals

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

* refactor: extract shared flow helper logic

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

* fix: make special flow tools openai-compatible

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

* fix: improve flow eval prompts and validation

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

* test: relax flow benchmark overfits

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

* test: record updated flow benchmark history

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

* fix: address flow review findings

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

* refactor: source flow chat special module prompt

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

* fix: narrow rawscript helper return type

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

* refactor: dedupe flow chat prompt guidance

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

* fix: relax flow test10 validation

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 16:22:39 +00:00
Ruben Fiszel a3f24aeff8 sqlx 2026-04-15 15:14:44 +00:00
centdixandClaude Opus 4.5 f1e84cb088 chore: add backend preview validation to ai evals (#8827)
* feat: add backend preview validation to ai evals

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

* fix: refresh shared preview workspace assets

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

* fix: harden shared backend preview validation

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 15:11:25 +00:00
Ruben Fiszel 3aa279cfd7 nit tx commit cj 2026-04-15 12:05:11 +00:00
centdixandClaude Opus 4.5 5c179e5448 fix: preserve gemini thought signatures in ai chat (#8837)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 11:49:57 +00:00
Diego ImbertandClaude Opus 4.6 12d0a3de08 fix: parse assets on inline script module creation to avoid false toast (#8835)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:01:07 +00:00
Ruben Fiszelandrubenfiszel a98f5b9dfd chore(main): release 1.684.1 (#8834)
* chore(main): release 1.684.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-14 21:48:03 +00:00
Ruben Fiszel 75e204dad1 pin tree-sitter 2026-04-14 21:36:00 +00:00
Ruben FiszelandClaude Opus 4.5 6158ff2ebe fix: stop escalating missing email recipients to critical alert (#8833)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 21:21:50 +00:00
Ruben Fiszelandrubenfiszel 8ee14644f4 chore(main): release 1.684.0 (#8831)
* chore(main): release 1.684.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-14 20:58:06 +00:00
Ruben Fiszel f273341759 remove axios deps 2026-04-14 20:50:20 +00:00
hugocasaandClaude Opus 4.6 64ba3a632e feat: cascade trigger script_path on runnable rename + fix trigger permissioned_as (#8823)
* feat: cascade trigger script_path updates on script/flow rename + fix trigger permissioned_as

Backend: When a script or flow path is renamed, automatically update script_path
across all trigger tables (http, email, kafka, websocket, postgres, mqtt, nats,
sqs, gcp, native). Long-running triggers get server_id reset to force restart.
Native triggers additionally get async webhook URL re-registration with external
services (Google, Nextcloud) via token rotation + handler.update().

Frontend: Fix permissioned_as handling across all trigger/schedule editors:
- Allow setting permissioned_as on trigger creation (not just edit) for admins
- Fix hasChanged detection for permissioned_as changes
- Fix FolderEditor group selector showing usernames instead of group names

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

* refactor: rename script_rename -> runnable_rename for consistency

"Runnable" is the correct term for both scripts and flows.

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

* refactor: remove native trigger re-registration from runnable rename

Keep it simple — only update script_path in the DB for non-native triggers.
Native triggers require external service re-registration (token rotation +
webhook URL update) which adds significant complexity; defer to a future PR.

sqlx files for the updated CTE query need regenerating.

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

* sqlx

* refactor: call update_triggers_script_path directly, remove windmill-trigger wrapper

No need for the extra module/dep — the common function is called directly
from scripts.rs and flows.rs with inline error mapping.

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

* fix: reject empty principal in folder default permissioned_as validation

`u/` and `g/` (no name after prefix) were passing validation. Use regex
to require at least one character after the prefix.

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

* fix: prevent async folder-default load from overwriting user's permissioned_as choice

Split the initialization effect into two: one that resets on trigger switch
(tracks permissionedAs), and one that handles folder default loading (tracks
folderDefault.value). The second effect is guarded by a userHasSelected flag
set in handleSelect, so a late-arriving folder default doesn't wipe the
user's explicit selection.

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

* lock

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:42:13 +00:00
Ruben FiszelandClaude Opus 4.6 aebf758412 fix: allow dedicated flow substeps to inherit parent tag (#8832)
Flow substeps that inherit the parent flow's tag were re-validated
against CUSTOM_TAGS, which rejected dedicated flow tags
(`{workspace_id}:flow/{path}`) since they are never user-registered.
The parent flow's tag was already validated at push time, so skip the
redundant check when the substep simply inherits it.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:38:37 +00:00
hugocasaandClaude Opus 4.6 91064ce857 feat(frontend): improve permissions drawer UX and auto-share resource variables (#8824)
* feat: improve permissions drawer UX and auto-share resource variables

- When sharing a resource, automatically detect linked variables ($var: refs)
  and offer to apply the same permission changes via a toggle (on by default)
- Rename "Share" to "Permissions" across all dropdown menus (resources, variables,
  scripts, flows, apps, schedules, triggers)
- Replace Share icon with Shield icon for consistency
- Show default permissions (folder/user/group) as a separate section in the drawer
- Move item path into drawer title ("Permissions for {path}")

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

* fix: guard async results against stale drawer state and null-safe extra_perms

- Add path staleness check in loadLinkedVarPaths and loadDefaultPerms
  to prevent late async responses from overwriting state when the drawer
  was reopened for a different item
- Use ?? {} fallback for folder.extra_perms which can be undefined

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:30:36 +00:00
Ruben Fiszel 2c1fe88fed fix ws_specific grant 2026-04-14 20:25:32 +00:00
Diego ImbertandClaude Opus 4.5 7fe639d91e fix: hide serial types in column type dropdown for existing columns (#8828)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 20:17:51 +00:00
Diego Imbert 06fe809ecc fix: DB Manager delete/update for timestamp and serial types (#8830)
* Fix time(stamp)(tz) comparisons in pg_executor

* fix serial bug

* UPDATE and DELETE use primary key only
2026-04-14 20:17:39 +00:00
Diego Imbert 5069a3b2e3 Better S3 error context (#8829) 2026-04-14 20:17:28 +00:00
Diego ImbertandClaude Opus 4.6 e1dbce02c2 fix: compute wall-clock duration for flow job groups in CLI (#8826)
The total duration of a for-loop/branchall group was computed as the
naive sum of all iteration durations. This is wrong for parallel
execution and doesn't account for orchestration overhead. Instead,
compute actual wall-clock time as max(completed_at) - min(started_at).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:16:50 +00:00
Ruben Fiszelandrubenfiszel 6bb80ff28b chore(main): release 1.683.2 (#8820)
* chore(main): release 1.683.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-14 00:23:20 +00:00
5b3913052e refactor: convert read-hot globals to AtomicBool/I64 and ArcSwap (#8815)
* refactor: extract load helpers from reload_setting family

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

* refactor: convert atomic primitive globals to AtomicBool/AtomicI64

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

* refactor: convert CRITICAL_*/HUB_API_SECRET/INSTANCE_EVENTS_WEBHOOK/JWT_SECRET to ArcSwap

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

* chore: pin ee-repo-ref to arcswap-refactor EE branch commit

* refactor: convert BASE_URL/HUB_BASE_URL/MIN_VERSION/LICENSE_KEY*/LICENSE_KEY_ID to ArcSwap

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

* refactor: convert worker hot-path globals to ArcSwap (WORKER_CONFIG et al)

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

* chore: pin ee-repo-ref to combined arcswap-urls+worker EE commit

* chore: update ee-repo-ref to d8be8f88cb8898c8f6b27421989d53528223815d

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

Previous ee-repo-ref: c375aaaac9ec0fc0480993627d0defc8054c31a4

New ee-repo-ref: d8be8f88cb8898c8f6b27421989d53528223815d

Automated by sync-ee-ref workflow.

* fix: cleanup unused imports + fix 2 missed WORKER_CONFIG readers

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

* chore: update ee-repo-ref to ce0f8fbbbde09c4a858312d2d8716d224e99042c

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

Previous ee-repo-ref: 450b601b5aba0ca0b2045f4b5071aa8701b4bfb7

New ee-repo-ref: ce0f8fbbbde09c4a858312d2d8716d224e99042c

Automated by sync-ee-ref workflow.

* fix: secret_backend_integration test — BASE_URL.write().await → .store()

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

* refactor: convert APP_WORKSPACED_ROUTE to AtomicBool for symmetry with HTTP_ROUTE_WORKSPACED_ROUTE

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

* chore: update ee-repo-ref to e587df8 (post-#535 merge)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-14 00:04:10 +00:00
Ruben FiszelandClaude Opus 4.6 4dc54ca3aa fix: persist indexer max_index_time_window_secs setting (#8821)
* fix: persist indexer max_index_time_window_secs setting

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

* feat: toggle UX for indexer time window cap

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 23:56:38 +00:00
Ruben FiszelandClaude Opus 4.6 89c8e4bb96 fix: detect WAC v2 Python workflows that only use step() (no @task) (#8819)
is_wac_v2_py required both @workflow and @task, so a workflow using
only inline step() calls fell through to the regular Python path and
returned the raw coroutine object instead of its awaited result. Match
the TS detector and accept @workflow alone.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 23:22:30 +00:00
Ruben Fiszelandrubenfiszel eb85da932a chore(main): release 1.683.1 (#8817)
* chore(main): release 1.683.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-13 22:49:27 +00:00
Ruben FiszelandClaude Opus 4.6 f7f26b3224 fix: use OpenAPI 3.0 nullable pattern for getOpenDeploymentRequest (#8816)
The response schema used `oneOf: [$ref, {type: null}]` which is
OpenAPI 3.1 syntax, but the spec is declared as 3.0.3. Both
oapi-codegen (Go) and openapi-python-client rejected it, breaking
the client release jobs. Switched to the standard 3.0 pattern
(`nullable: true` + `allOf: [$ref]`), matching existing usage at
openapi.yaml:21410.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:41:59 +00:00
Ruben Fiszelandrubenfiszel e0066b266f chore(main): release 1.683.0 (#8802)
* chore(main): release 1.683.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-13 22:21:47 +00:00
42d3e8c789 fix: enrich OTEL log records with per-request LogContext (#8812)
* fix: enrich OTEL log records with per-request LogContext

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

* chore: add otlp_smoke example for manual OTEL log bridge verification

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

* chore: update ee-repo-ref to 5d6b713b74fc46735807f5c32883002e8d976fbc

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

Previous ee-repo-ref: 45959d063bc941c567488d330b5819601cdd2d3d

New ee-repo-ref: 5d6b713b74fc46735807f5c32883002e8d976fbc

Automated by sync-ee-ref workflow.

* refactor: store LogContext in ArcSwap instead of Mutex

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

* chore: pin ee-repo-ref to ArcSwap branch commit

* chore: update ee-repo-ref to be2f3d4d11bb7110200524d7157caab3aac53996

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

Previous ee-repo-ref: 45b4d7963a9ebcd583d1a87abe7d07d3d521584a

New ee-repo-ref: be2f3d4d11bb7110200524d7157caab3aac53996

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-13 21:50:50 +00:00
centdixandClaude Opus 4.5 c889a185d5 refactor: extract flow delete helpers (#8746)
* refactor: extract flow delete helpers

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

* refactor: unify flow delete planning

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

* refactor: stabilize flow delete execution

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

* refactor: simplify flow delete plan execution

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-13 20:35:01 +00:00
Ruben Fiszel baeb202037 nit npm check 2026-04-13 20:31:28 +00:00
hugocasaandClaude Opus 4.6 9fb78164b4 feat: allow non-admins to create and edit HTTP triggers (#8810)
* feat: allow non-admin users to create HTTP triggers with forced workspaced routes

Non-admin users can now create and fully edit HTTP triggers, but are forced
to use workspaced routes (workspace-prefixed URLs). Instance-wide routes
remain admin-only to prevent cross-workspace URL conflicts.

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

* fix: add missing RLS INSERT/DELETE policies for http_trigger table

Non-admin users were blocked by row-level security when creating HTTP triggers.
Added INSERT, DELETE, see_own, and see_member policies matching other trigger tables.

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

* feat: allow user paths for HTTP triggers

Remove the hideUser restriction on the Path component so HTTP triggers
can be created under user paths (u/username/...) in addition to folder paths.

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

* chore: remove added note from instance settings description

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

* fix: allow non-admins to edit non-workspaced routes without changing route config

Non-admins can now open and edit existing non-workspaced HTTP triggers
(created by admins) as long as they don't modify route_path, http_method,
or workspaced_route. The workspaced prefix is only forced on new triggers.

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

* fix: allow non-admins to change route_path on workspaced routes

The prevent_route_path_change DB trigger blocked all route_path changes
for windmill_user, even on workspaced routes. Now only instance-wide
(non-workspaced) routes are protected.

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

* fix: add explicit GRANT and force workspaced routes in OpenAPI generator

- Add explicit GRANT INSERT, DELETE on http_trigger to windmill_user
  for safety on customer instances
- Force workspaced_route: true for non-admins in OpenAPI route generator

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:43:49 +00:00
64c58c824f feat: add deploy restriction rule and fork review requests (#8804)
* feat: add deploy restriction rule and fork review requests

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

* chore: update ee-repo-ref.txt for fork review requests

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

* fix: address PR review comments on fork review requests

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

* refactor: rename fork review requests to deployment requests

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

* chore: update ee-repo-ref.txt for deployment request rename

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

* refactor: inline deployment request panel into deploy layout

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

* fix: place Request deployment button to the left of Deploy

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

* refactor: inline fork triggers into main deploy list

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

* refactor: open real trigger detail drawer for inline fork triggers

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

* feat: email notifications for merge completion and reply pings

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

* fix: update deployment_request + protection_rule tables on workspace id rename

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

* chore: update ee-repo-ref to 972893c3870e4c4a70a35748abed282d88904805

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

Previous ee-repo-ref: 5684d1c17d930b17849c1e5d7577891e64682d45

New ee-repo-ref: 972893c3870e4c4a70a35748abed282d88904805

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-13 17:10:37 +00:00
Ruben Fiszel b3ef4bc26c perf: add inline-persist fast path for WAC v2 step() (#8807) 2026-04-13 16:49:53 +00:00
Ruben FiszelandClaude Opus 4.6 3f5841f84d feat: instance-level ruff config auto-pulled by LSP container (#8803)
* feat: add instance-level ruff config auto-pulled by LSP container

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

* refactor: move ruff config to new LSP tab in instance settings

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:04:49 +00:00
Ruben FiszelandClaude Opus 4.6 78a877eb96 avoid lock file race in repro_diffname CLI test on windows (#8811)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:27:36 +00:00
hugocasaandClaude Opus 4.5 378ba78284 fix: silence user-facing toast for non-critical hub script tracking error (#8808)
* fix: silence user-facing toast for non-critical hub script tracking error

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

* n

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-13 14:21:54 +00:00
hugocasaandClaude Opus 4.6 95411b2563 feat: display agent message in flow graph (#8806)
* feat: display message and web search content in agent graph node status

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

* feat: use markdown renderer for agent message output

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

* fix: revert web search output display — content not useful

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

* fix: improve web search alert text

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

* fix: align message title styling with other node status sections

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:39:14 +00:00
Ruben FiszelandClaude Opus 4.5 b6f1cc70cd fix(cli): make cli help resilient to npm registry fetch failures (#8809)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-13 13:38:38 +00:00
centdix cdcc56461b feat: add black-box ai eval benchmarks (#8618) 2026-04-13 14:05:46 +02:00
Ruben FiszelandClaude Opus 4.6 60211c1d19 feat: folder default_permissioned_as rules for ownership defaults on deploy (#8801)
* feat: add folder default_permissioned_as rules for ownership defaults on deploy

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

* fix: remove unnecessary auth guard on default_permissioned_as — rules are advisory only

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

* chore: regenerate system prompts with new CLI commands

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

* fix: address CI review findings — TOCTOU, race condition, email validation, type coercion

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

* fix: add sqlx offline cache for test queries (fixes cargo_test CI)

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

* fix: address remaining review findings — incomplete request bodies, dead code, redundant import

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

* fix: address remaining review findings — full script fields, reactive stores, catch-all validation

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

* fix: app/schedule/trigger set-permissioned-as fetch remote first to avoid data loss

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

* fix: app set-permissioned-as avoid creating redundant app version

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

* feat: compact user/group toggle + select for folder default_permissioned_as rules

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

* feat: collapse default_permissioned_as section by default in folder editor

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

* feat: include default_permissioned_as in FolderFile CLI type for YAML round-trip

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

* fix: process folder.meta changes before items in push to apply new rules immediately

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

* fix: clone default_permissioned_as on fork/rename + add full lifecycle tests

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

* test: add no-op guarantee test — folder without rules behaves like before

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

* refactor: rename cliBehavior to syncBehavior — more accurate scope

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:14:07 +00:00
Ruben Fiszelandrubenfiszel 6cebc6f61b chore(main): release 1.682.0 (#8798)
* chore(main): release 1.682.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-10 17:41:42 +00:00
Ruben FiszelandClaude Opus 4.6 59c457a138 feat: enrich hanging flow error with worker and service log info (#8800)
* feat: enrich hanging flow error with worker and service log info

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

* fix: address PR review on hanging flow diagnostics

- Widen log_file lookup window to [-90s, +30s] around worker last ping
  so the batch containing the crash is captured (log files are
  minute-aligned; looking forward only was missing the relevant bucket).
- Log a warning on log_file query errors instead of silently swallowing,
  so a misconfigured table is not reported as "no log files found".
- Note that service log download URLs require S3/parquet collection.
- Fix memory display when only worker_memory_total is known.
- Regenerate sqlx offline cache for the new/modified queries.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:19:20 +00:00
Ruben FiszelandClaude Opus 4.6 b783bf2d83 fix: show full path on hover in deploy drawer and widen drawer (#8799)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:40:23 +00:00
Ruben FiszelandClaude Opus 4.6 9c85565221 fix: bypass OTEL MITM tracing proxy for git sync jobs (#8796)
Git sync runs as a DeploymentCallback job. When the OTEL MITM tracing
proxy is enabled, all HTTP/HTTPS traffic from the script is rerouted
through a local intercepting proxy that chains to the corporate upstream
proxy. Git's HTTPS to GitHub fails in this setup (TLS interception with
chained CONNECT tunneling is fragile, and git's CA env handling diverges
from what the proxy injects), so customers see "GitHub.com URL couldn't
be reached" until they disable OTEL.

Detect DeploymentCallback jobs in get_proxy_envs_for_lang and fall back
to the stock PROXY_ENVS so git talks to the corporate proxy directly,
unmodified. The git sync script is system code; we don't need HTTP spans
for it.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:32:19 +00:00
Ruben FiszelandClaude Opus 4.6 e48c7cf448 move CiTestResult schema outside python-client inline markers (#8795)
CiTestResult was defined between the INLINE START/END markers, which
python-client/build.sh strips and replaces with a wildcard $ref to
openflow.openapi.yaml, breaking the PyPI publish job.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:28:09 +00:00
Ruben Fiszelandrubenfiszel 8b2a8882bc chore(main): release 1.681.0 (#8769)
* chore(main): release 1.681.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-10 14:53:04 +00:00
5eb9a2e965 add instance onboarding telemetry (#8792)
* [ee] feat: add instance onboarding telemetry

Update ee-repo-ref to include instance_onboarding telemetry field
in the daily stats payload.

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

* chore: update ee-repo-ref to 5f912375340225876a8c1740c3301f39cd6cbd6d

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

Previous ee-repo-ref: b0b10d81060ab6dabee81a5a067ffadc6b48e074

New ee-repo-ref: 5f912375340225876a8c1740c3301f39cd6cbd6d

Automated by sync-ee-ref workflow.

* sqlx

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-04-10 14:48:38 +00:00
946848feef fix: limit multi-runnable dedicated workers to one job at a time (#8782)
* feat: thread concurrency semaphore through dedicated worker executors

Pass the concurrency_semaphore parameter through bun, deno, and python
start_worker functions to handle_dedicated_process. Also fix the
DedicatedWorkersSelector to use listWorkspacesAsSuperAdmin (so all
workspaces including admins are visible) and skip loading when disabled.

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

* chore: update ee-repo-ref to limit-workers-one-job branch

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

* chore: update ee-repo-ref to 54037e77cdd37777560755fef7075d35906c96d8

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

Previous ee-repo-ref: 56890ea8fca2c1c44a1338a27011b4dd1137d9c9

New ee-repo-ref: 54037e77cdd37777560755fef7075d35906c96d8

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-04-10 14:35:13 +00:00
Diego Imbert 3d43d31aba fix: refresh custom instance user password if auth failed (#8787)
* Refresh custom instance user pwd if connection failed

* No longer need to check on startup

* nit: unneeded inner function

* fix
2026-04-10 14:26:53 +00:00
Diego ImbertandClaude Opus 4.6 8957d8f19b fix: bypass sql type injection during formatting to prevent offset corruption (#8786)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:26:33 +00:00
Diego Imbert 3c64a4282d Prompt to analyse assets for whole flow on undetected assets (#8784) 2026-04-10 14:26:20 +00:00
Ruben FiszelandClaude Opus 4.6 ec9cec1d02 fix: treat empty global setting strings as unset (#8793)
* fix: treat empty global setting strings as unset

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

* fix: close protected-setting whitespace gap in diff and preserve empty ws override

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:23:37 +00:00
Ruben FiszelandClaude Opus 4.6 09666af157 refactor(git-sync): remove force_branch UI option (#8794)
The new workspaces: section in wmill.yaml lets the CLI auto-select the
right entry by matching baseUrl + workspaceId against the existing
--base-url and --workspace flags the backend already passes, making the
force_branch override redundant.

Backend field and serializer are intentionally left intact for backward
compat with any repository that already has force_branch saved.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:22:25 +00:00
6cf7ffc26b feat(vault): add skip_ssl_verify option for HashiCorp Vault (#8791)
* [ee] feat(vault): add skip_ssl_verify option for HashiCorp Vault

Adds an optional skip_ssl_verify boolean to VaultSettings so
self-signed Vault deployments can be used in development without
needing a custom CA bundle. The flag is surfaced as a Toggle in the
HashiCorp Vault section of the secret backend instance settings and
plumbed through to the EE Vault HTTP client builder.

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

* chore: update ee-repo-ref to bcfb663f9e902539abbbf69c517715eb8d4ce8f9

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

Previous ee-repo-ref: 7e1372b8f59fe81aaf61212970ebdf2286be864d

New ee-repo-ref: bcfb663f9e902539abbbf69c517715eb8d4ce8f9

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-10 13:55:22 +00:00
ce3e676f4a feat: list external JWT tokens in instance settings (#8783)
* [ee] feat: add external JWT tokens listing in instance settings

Add the ability for superadmins to view all external JWT tokens that have
been used for authentication, along with their claim metadata.

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

* chore: update ee-repo-ref.txt

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

* refactor: move external JWT tokens listing to users tab

- Move list endpoint from /oidc/ext_jwt_tokens to /users/ext_jwt_tokens
- Display as a sub-tab below the instance Users tab, only shown when tokens exist
- Use DataTable's built-in load-more pattern for pagination
- Add "Recently active only" toggle (tokens used in the last 30 days)

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

* feat: add dev_override cargo feature to windmill-common

* feat: show placeholder for legacy external JWT entries

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

* chore: update ee-repo-ref to 62a462461271b900351c18b0ab1ca78651154b2a

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

Previous ee-repo-ref: 7b493a337abe00a47cf9d94847babe3cb3a6799f

New ee-repo-ref: 62a462461271b900351c18b0ab1ca78651154b2a

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-10 13:11:00 +00:00
Ruben FiszelandClaude Opus 4.6 4fff89f98c fix: hide legacy global_settings.worker_configs ghost row (#8790)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 06:00:03 +00:00
Ruben FiszelandClaude Opus 4.6 d243eb31b0 fix: CLI falls back to workspace whoami for workspace-scoped tokens (#8789)
* fix: CLI falls back to workspace whoami when global whoami is 401

Workspace-scoped tokens (token.workspace_id set) cannot call
/api/users/whoami — the backend's token lookup filters by workspace_id
which is NULL on global paths, so auth returns 401 before the handler
runs. This breaks the CLI entirely: requireLogin calls globalWhoami at
the start of every command, so no command works with a
workspace-scoped token, not even `wmill workspace whoami`.

Fix it CLI-side: if the global whoami returns 401, fall back to the
workspace-scoped /api/w/{w}/users/whoami using the workspace already
known from the CLI profile, and adapt the response shape to
GlobalUserInfo. Also drop the redundant second globalWhoami call in
`wmill workspace whoami` — use requireLogin's return value instead.

No backend changes: the workspace_id binding on the token stays
strictly enforced for every global endpoint.

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

* fix: use name-based ApiError check in whoami fallback

Review feedback from PR #8789: `instanceof ApiError` can silently
return false when bundling produces multiple module instances of
`gen/core/ApiError.ts` (bun build for npm, JSR dev path), which would
skip the workspace-whoami fallback and reintroduce the exact bug this
PR fixes. Match the name-based check already used at
`cli/src/main.ts:232` and drop the `ApiError` import.

Also add a comment on `workspaceUserToGlobalUserInfo` listing the
fields that aren't derivable from the workspace-scoped User response
and are filled with placeholder values, so future callers don't trust
them downstream.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 05:55:53 +00:00
Ruben FiszelandClaude Opus 4.6 a7512f9034 chore: update git sync script version to 28191 (#8788)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:18:01 -04:00
Ruben FiszelandClaude Opus 4.5 5b97092997 feat: unify CLI config to workspaces, deprecate gitBranches/environments (#8767)
* refactor: unify CLI config to workspaces, deprecate gitBranches/environments

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

* chore: update frontend examples and regenerate system prompts for workspaces config

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

* test: update test files to use workspaces config instead of gitBranches

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

* fix: handle --branch with --base-url correctly in sync pull/push

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

* feat: warn when --workspace overrides auto-detected branch or misses config entry

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

* feat: show reason why workspace was selected in log message

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

* docs: clarify specificItems file naming uses gitBranch as suffix

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

* refactor: rename branch-specific to workspace-specific, use workspace name as file suffix

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

* refactor: rename branch-specific to workspace-specific, add comprehensive integration tests

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

* refactor: simplify bind and init to be workspace-centric

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

* refactor: make bind/unbind interactive with --workspace and --branch flags

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

* refactor: make bind interactive with profile selection, workspace name, and optional branch

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

* refactor: init offers to bind workspace using same flow as wmill workspace bind

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

* fix: skip backend git-sync check in init when no workspace was bound

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

* fix: skip all API calls in init when no workspace was bound

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

* feat: log when RT namespace is skipped, offer to generate it after bind

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

* fix: warn when no workspace bound during init

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

* fix: init git-sync check uses bound workspace, not active profile

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

* fix: init uses selected profile directly, avoids re-resolving and duplicate prompt

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

* fix: init skips requireLogin, uses bound profile token directly

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

* feat: auto-pick or prompt workspace from config when no branch matches

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

* fix: show configured workspaces list and bind hint in resolution messages

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

* fix: cache bound profile to avoid duplicate profile selection prompts in init

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

* fix: hoist boundProfile scope, add 2 comprehensive integration tests covering all flows

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

* fix: rt.d.ts prompt defaults to no when file exists, better description

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

* fix: remove empty overrides from generated config, add specificItems hint

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

* fix: add inline comments for non-trivial fields, add overrides/promotionOverrides hints to bound workspaces

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

* chore: regenerate system prompts

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-09 19:46:34 +00:00
Diego Imbert 29e7701972 nit text too long (#8785) 2026-04-09 19:33:16 +00:00
hugocasaandClaude Opus 4.6 435b25e6a4 feat: add user offboarding flow with object reassignment (#8647)
* feat: add user offboarding flow with object reassignment

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

* fix: require new_operator for permissioned_as when reassigning to folder

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

* fix: update on_behalf_of_email on scripts/flows during offboarding

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

* refactor: extract offboarding to separate module and add integration tests

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

* fix: delete tokens, add operator preview counts, remove token reassignment UI

Tokens are now always deleted during offboarding. Preview now shows
scripts/flows/apps with on_behalf_of and schedules/triggers with
permissioned_as referencing the departing user (even outside their path).
Token reassignment UI removed since webhooks break on path changes anyway.

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

* feat: rich preview with path lists, warnings, and downloadable report

Preview now returns full path lists (not just counts) for owned objects
and objects executing on behalf of the user. Adds warnings for:
- HTTP triggers (webhook URLs will change)
- Email triggers (addresses will change)
- Broken $var:/$res: references in resources/variables
Frontend provides "Export list" button to download affected content.

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

* test: add coverage for dynamic queries (triggers, extra_perms, operator schedules)

Adds HTTP trigger, extra_perms reference, and shared schedule to test
fixture. Tests verify that non-macro sqlx queries (trigger reassignment,
extra_perms cleanup, operator schedule update) work correctly.

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

* fix: remove broken_references, add full dynamic query test coverage

Remove broken_references field from preview (user's resources/variables
are already in the owned paths list). Add shared HTTP trigger fixture
to test all dynamic query paths: trigger operator preview (line 232),
trigger permissioned_as update for non-user-path (line 951), and
extra_perms cleanup on trigger tables (line 983).

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

* feat: add referencing field to preview for content/value path references

Preview now includes a 'referencing' section listing scripts (by content),
flows (by value JSON), apps (by policy/extra_perms), and resources (by value)
that contain references to u/{username}/ paths. These references may break
after reassignment. Shown in export list and as a warning in the UI.

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

* fix: rename objects to items in UI, detect on_behalf_of items in hasItems

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

* fix: replace remaining objects with items in UI text

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

* refactor: rename operator to on_behalf_of, separate owned vs on-behalf UI sections

- Rename new_operator to new_on_behalf_of_user in API and frontend
- Rename op_ prefixed variables to obo_ in backend
- UI now shows separate sections for owned items and items running
  on behalf, with the operator selector shown only when needed
- canSubmit logic updated: operator needed for folder targets OR
  when on-behalf items exist

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

* feat: CSV export, side-by-side layout, always accept new_on_behalf_of_user

- Export affected items as CSV instead of text
- Owned items and on-behalf items shown side by side in summary boxes
- new_on_behalf_of_user always accepted (defaults to target user for
  user targets, required for folder targets)
- On_behalf_of selector always visible, auto-defaults when user target
  is selected

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

* fix: proper pluralization and bottom-aligned counts in summary boxes

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

* fix: stack info boxes vertically, referencing box as warning style at top

Info boxes (owned, on-behalf, referencing) now one per row instead of
side-by-side. Referencing box uses warning colors. Webhook/email trigger
alerts shown below boxes. Proper pluralization in global modal too.

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

* fix: CSV exports only referencing items, export button inside warning box

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

* feat: use ToggleButtonGroup for user/folder, add reassign toggle on remove

- User/Folder selection now uses ToggleButtonGroup component
- When removing a user, a "Reassign items before removing" toggle lets
  the admin skip reassignment and just delete directly
- In reassign-only mode, the toggle is not shown (always reassigns)

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

* feat: show token details with labels and scopes in preview

Preview now returns token label, scopes, and expiration instead of just
a count. Frontend shows a dedicated token box listing each token with
its scopes. Test updated to verify token label in preview response.

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

* feat: extract shared offboarding components, per-type trigger links, hash deep linking

- Extract OffboardItemsBox, OffboardReassignControls, OffboardWorkspaceSection,
  and offboarding-utils.ts as shared components used by both workspace and global modals
- Change triggers in OffboardAffectedPaths from Vec<String> to HashMap<String, Vec<String>>
  so frontend knows which trigger page to link to
- Add hash-based deep linking to all 9 trigger pages and schedules page
- Preserve URL hash in updateQueryFilters across all trigger pages
- Only open editor drawer if the item is found in the list
- Reassign toggle at top with warning alert when disabled (both modals)
- Referencing items box uses yellow warning variant with expandable path links
- Cleaner labels: "Move u/{username}/* items to", "Update triggers/runnables permissions to"

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

* fix: rename on_behalf_of section label to match flow advanced settings

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

* chore: regenerate sqlx query cache

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

* fix: address PR review issues for offboarding

- Add 9 trigger tables to check_path_conflicts for user-friendly conflict messages
- Fix submit button no-op when user has only on-behalf items (show target selector, fix canSubmit)
- Only delete workspace user when reassignment entry exists (prevent orphaned objects)
- Add $azure_kv: prefix to vault secret query (match rename_user pattern)
- Use Svelte 5 onSelected callback instead of deprecated on:selected
- Make ScriptBuilder section label conditional on canPreserve
- Fix CSV export to include trigger paths via flattenPaths utility
- Fix test_offboard_reassign_only to remove conflicts and assert on response
- Parallelize workspace config fetches in global modal with Promise.all
- Delete tokens when deleting workspace user
- Return structured JSON from global offboard endpoint

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

* sqlx

* fix: address second round of PR review issues

- Accumulate per-workspace OffboardSummary in global offboard instead of returning zeros
- Delete workspace user unconditionally when delete_user=true (prevent orphaned usr rows)
- Filter archived/deleted scripts in check_path_conflicts to match preview
- Reset form state when workspace offboard modal reopens
- Move hashHandled=true inside trigger-found guard on all 10 deep-link pages

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

* test: improve offboarding integration tests

- Add second workspace to fixture for multi-workspace global offboard testing
- Add test_global_offboard_execution: verifies items reassigned across 2
  workspaces, user deleted from both, and password row deleted from instance
- Add test_offboard_invalid_target: verifies 400 for nonexistent user,
  nonexistent folder, and invalid target format
- Fix test_offboard_to_user: use single DELETE, add explicit new_on_behalf_of_user
- Fix test_global_offboard_preview: assert 2 workspaces instead of 1

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

* fix: address third round of PR review issues

- Fix ScriptBuilder tooltip to match conditional section label wording
- Clear stale conflicts in global modal on reopen
- Fix test_offboard_to_folder to assert on specific moved path, not pre-existing data
- Allow deleting user with zero items (show Offboard button, skip reassignment)

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

* fix: add global token deletion warning in instance-level offboard modal

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

* update sqlx

* fix: add raw_app path and dependency_map path reassignment to offboarding

Audit found these tables with user-scoped paths were not being updated:
- raw_app: mirrors app paths, needs path reassignment
- dependency_map: importer_path and imported_path reference user paths

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

* fix: move user cleanup to delete_workspace_user_internal, fix review issues

- Move extra_perms, folder owners, drafts, favorites, inputs, captures
  cleanup into delete_workspace_user_internal so any user deletion gets
  proper cleanup (not just offboard path)
- Fix flow INSERT missing labels and lock_error_logs columns (data loss)
- Fix validate_target returning 404 instead of 400 for nonexistent targets
- Fix canSubmit blocking delete when user has no items to reassign
- Fix token preview query filtering out tokens without scopes
- Fix token warning messages: workspace-level mentions webhooks/HTTP triggers,
  instance-level mentions API calls using credentials
- Fix "Schedules and triggers" -> "Triggers and runnables" wording
- Show token section at instance level only when tokens exist
- Show Offboard button at instance level when user has no items but deleteUser=true

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:31:36 +00:00
Ruben FiszelandClaude Opus 4.6 1deb31f1e0 fix: error on flow/app folder suffix format mismatch during sync push/pull (#8775)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:30:59 +00:00
c57c769dea feat: add CI test scripts with auto-trigger on deploy (#8736)
* feat: add CI test scripts with auto-trigger on deploy

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

* fix: fix annotation parser early return and handle renames correctly

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

* fix: move CI test results to top of script/flow detail pages

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

* fix: improve CI test results spacing, icon, and remove pass label

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

* feat: support one-line annotation and use script/path format

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

* feat: move CI test trigger logic to EE

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

* feat: move CI badge next to New badge and add deduplicated CI summary

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

* feat: add CI test e2e tests and fix nullable column annotations

Add integration tests for CI test annotation parsing (creates/removes
ci_test_reference rows) and the CI test results API (single + batch
endpoints). Add backend test for auto-trigger on deploy (private+python).

Fix sqlx LEFT JOIN LATERAL nullable column annotations in
get_ci_test_results and get_ci_test_results_batch queries — sqlx
cannot infer nullability from LATERAL subqueries, causing runtime
decode errors when no matching job exists.

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

* fix build/sqlx

* fix

* feat: CI test improvements and templates

- Fix windmill-dep-map/private feature propagation in worker, api-scripts,
  and api-flows Cargo.toml so CI test triggers actually fire in EE mode
- Clone ci_test_reference rows during workspace fork
- Add polling to CiTestResults component (refetch every 3s while running)
- Add running state and auto-refresh to ForkWorkspaceBanner CI summary
- Add yellow "CI test" badge on script list rows and detail page
- Fix Library badge border color (remove indigo border override)
- Add CI Test TypeScript and CI Test Python templates in ScriptBuilder
- Update sqlx offline cache
- Add debug tracing for CI test trigger in worker_lockfiles

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

* fix: add missing children prop to WorkspaceDeployLayout

Fixes svelte-fast-check type error when passing named snippets as
children content inside the component tag.

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

* fix: address PR review feedback

- Remove empty wrapper divs around CiTestResults, move mb-4 into component
- Add batch endpoint size cap (max 200 items)
- Add ON DELETE CASCADE to ci_test_reference workspace FK (new migration)
- Downgrade CI test trigger logs from info to debug
- Fix false-positive polling: only treat status='running' as running,
  not null status (CiTestResults, CompareWorkspaces, ForkWorkspaceBanner)
- Fix test numbering in integration tests

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

* chore: update ee-repo-ref to latest EE commit

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

* chore: update ee-repo-ref to d9d68c2406df0b59f413ea0b2cb24780a9817d04

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

Previous ee-repo-ref: d7ccd9b86da99ec056a0e8708e3637d64290387a

New ee-repo-ref: d9d68c2406df0b59f413ea0b2cb24780a9817d04

Automated by sync-ee-ref workflow.

* fix: treat queued jobs (job_id set, null status) as running

Jobs that have been pushed but not yet picked up by a worker have a
job_id but null status. Treat these as 'running' to avoid showing
misleading 'pass' badges or '0 passing'. Tests that were never
triggered (no job_id, null status) remain neutral/hidden.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-09 17:21:36 +00:00
centdixandClaude Opus 4.6 b73be37916 feat: add edit yaml button to raw app settings (#8771)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:21:02 +00:00
Diego Imbert 4b876392a0 feat: oauth manual connect option (#8770) 2026-04-09 17:19:25 +00:00
centdixandClaude Opus 4.6 5f57727a4d feat: allow selecting hub flows as raw app backend runnables (#8772)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:18:40 +00:00
Diego Imbert 6d36eca216 fix: Flow status viewer layout nits (avoid excess y space and scroll) (#8780) 2026-04-09 17:15:14 +00:00
Ruben FiszelandClaude Opus 4.6 3fb557a7f5 fix: flow step testing UX improvements (#8781)
* fix: flow dev page layout and compact toolbar improvements

- Fix JSON.parse error on /flows/dev page when editor not yet initialized
- Increase compact topbar threshold from 700px to 800px
- Reposition "Test flow" button below settings bar when pane is narrow on dev pages

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

* fix: flow step testing UX improvements

- Store and display logs for step test results (previewLogs in flowState)
- Add logs toggle button in output picker popover
- Fix AI proxy 401 in VS Code extension by passing OpenAPI.TOKEN
- Prevent output picker from closing when clicking Run on same node
- Make toggleOpen idempotent to avoid flicker
- Show loading placeholder in badge area during test execution
- Keep pin button visible during test runs
- Auto-refresh step history when new test completes

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

* fix: track previous previewJobId to avoid redundant history refreshes

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

* fix: dev page insert popover z-index, summary editing, output picker UX

- Add #flow-editor portal div to /flows/dev page for correct popover stacking
- Add summary text field at bottom of dev pages when a step node is selected
- Keep pin button visible during test runs
- Show loading placeholder badge to prevent content shift
- Exclude same-node run button from output picker outside-click detection
- Make toggleOpen idempotent when popover already open

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

* refactor: reuse findModuleInFlow instead of duplicated findModule

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:40:34 +00:00
Diego ImbertandClaude Opus 4.6 e63924e377 fix: disable scroll-to-change-number on number inputs (#8777)
* fix: disable scroll-to-change-number on number inputs

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

* style: add comment explaining wheel handler

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:39:49 +00:00
Diego ImbertandClaude Opus 4.6 3d02be98f7 fix: normalize multi-word pg types in build_parameters to fix float8 serialization (#8778)
Multi-word Postgres type names like "double precision" caused the SQL
parser regex to fail (no spaces allowed in type group), falling back to
otyp="text". When Postgres inferred float8 for the column, the
text-typed null couldn't serialize, breaking DB Manager inserts/updates.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:39:32 +00:00
Ruben FiszelandClaude Opus 4.6 89920e77f3 fix: flow dev page layout and compact toolbar improvements (#8776)
- Fix JSON.parse error on /flows/dev page when editor not yet initialized
- Increase compact topbar threshold from 700px to 800px
- Reposition "Test flow" button below settings bar when pane is narrow on dev pages

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:08:35 +00:00
Henri Courdent 11ecb5a774 Volumes link (#8773) 2026-04-09 08:00:35 -04:00
Ruben FiszelandClaude Opus 4.6 506b7f55e1 fix: zero-downtime coordinated restarts for OTEL and other setting changes (#8768)
* fix: zero-downtime coordinated restarts for OTEL and other setting changes

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

* fix: use background_task_state for server heartbeats and fix stale heartbeat detection

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

* feat: show restart propagation toast when saving settings that trigger server restarts

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:35:27 +00:00
Ruben Fiszelandrubenfiszel 25f4242a87 chore(main): release 1.680.0 (#8757)
* chore(main): release 1.680.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-08 16:39:17 +00:00
Ruben Fiszel 609d94aa31 fix bun lock 2026-04-08 16:22:56 +00:00
Ruben Fiszel 80c8e076fc cli nit 2026-04-08 16:16:34 +00:00
Diego ImbertandClaude Opus 4.5 d2992af8be refactor: move ws_specific from resource column to separate table (#8766)
* Move ws_specific to separate table

* on delete cascade

* feat: handle ws_specific on resource rename and delete

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

* is_false never used

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-08 16:10:41 +00:00
Ruben FiszelandClaude Opus 4.6 e36d440a25 fix: resolve esbuild host/binary version mismatch in app sync push (#8765)
* fix: resolve esbuild host/binary version mismatch in app sync push

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

* Revert "fix: resolve esbuild host/binary version mismatch in app sync push"

This reverts commit 8822614f8e.

* fix: update esbuild to 0.28.0 and pin version exactly

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:09:58 +00:00
Ruben FiszelandClaude Opus 4.6 fa668707c0 fix: move alert config from config table to global_settings (#8762)
* feat: move alert config from config table to global_settings

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

* chore: update ee-repo-ref.txt

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

* refactor: rename alert setting to alert_job_queue_waiting

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

* chore: update ee-repo-ref.txt

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

* test: add CLI unit tests for pullInstanceConfigs/pushInstanceConfigs

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

* chore: update ee-repo-ref.txt

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

* chore: update ee-repo-ref.txt to merged main

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:54:44 +00:00
Diego ImbertandClaude Opus 4.5 c69f10d20d fix: skip serializing ws_specific on resources when false (#8764)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-08 15:53:01 +00:00
Ruben FiszelandClaude Opus 4.6 84778ca3e9 chore: update ee-repo-ref.txt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:52:10 +00:00
Ruben FiszelandClaude Opus 4.6 c4c003dab8 chore: update ee-repo-ref.txt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:51:14 +00:00
Diego ImbertandClaude Opus 4.6 470b8aa5f1 feat: add status indicator dots to parallel loop iteration picker (#8761)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:26:21 +00:00
Diego Imbert 5713760b7a Fix TS typechecker for Ducklake emitting error for NULL params (#8760) 2026-04-08 12:43:41 +00:00
Ruben Fiszel f5c9ff709b sqlx 2026-04-08 06:11:22 +00:00
Ruben FiszelandClaude Opus 4.6 f0bb270723 add missing delete_after_secs column to explicit SQL queries (#8759)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 06:00:37 +00:00
Ruben Fiszel 4c16877366 update sys prompts 2026-04-08 05:38:38 +00:00
Ruben FiszelandClaude Opus 4.5 4342c18541 feat: add CLI workspace merge command and enhance fork with datatable/color support (#8756)
* feat: add CLI workspace merge command and enhance fork with datatable/color support

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

* fix: abort fork on git branch failure, per-datatable error handling, guard resetDiffTally

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

* test: add fork/merge integration tests covering full cycle

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

* fix: support deploying deletions during fork merge (archive/delete in target)

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

* refactor: share deploy logic between CLI and frontend via windmill-utils-internal

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

* fix: revert frontend to self-contained deploy, fix failure_module handling

The frontend imports windmill-utils-internal from npm (published v1.3.4)
which doesn't have the new deploy module yet. Revert frontend to its own
self-contained implementation with two improvements:
- Pass failure_module to getAllModules in flow deploy and getItemValue
- Add deleteItemInWorkspace for deploying deletions during merge

The shared deploy.ts in windmill-utils-internal remains for CLI use.

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

* feat: share deploy logic via published windmill-utils-internal, add comprehensive integration tests

- Publish windmill-utils-internal v1.3.8 with DeployProvider interface
- Frontend now uses shared deploy module (deployItem, deleteItemInWorkspace,
  checkItemExists, getOnBehalfOf, getItemValue) via provider adapter
- Add 4 new integration test sub-tests: all item types, secret variables,
  special characters, partial deploy + resetDiffTally

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

* fix: remove unused folderName function from frontend utils_workspace_deploy

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-08 05:29:48 +00:00
Ruben Fiszel 01e6414ddb Squashed commit of the following:
commit a5400b92cc4d523589d7e3c98d866c56d950dd9f
Author: Ruben Fiszel <ruben@windmill.dev>
Date:   Wed Apr 8 04:24:25 2026 +0000

    fix
2026-04-08 04:25:26 +00:00
2d18a68099 feat: add scheduled job deletion with configurable retention period (#8753)
* feat: add scheduled job deletion with configurable retention period

Extends delete_after_use with delete_after_secs to enable configurable
retention periods for job args/result/logs. At completion, jobs can be
scheduled for future deletion via a new job_delete_schedule table,
processed by a monitor task. Supports per-script, per-flow, and
per-flow-step configuration. Backward compatible.

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

* feat: add integration tests, revert query! macros, fix review issues

- Add integration tests for resolve_delete_after_secs, schedule_job_deletion,
  flow-level and module-level delete_after_secs, backward compat
- Revert sqlx::query() back to sqlx::query!() macros for compile-time safety
- Regenerate sqlx offline cache
- Fix FlowModule/NewScript/FlowValue constructions in all test files
- Fix autoscaling_ee.rs for updated script_path_to_payload return type

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

* chore: update ee-repo-ref.txt for autoscaling_ee fix

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

* fix: gate cleanup_scheduled_job_deletions behind enterprise feature

Prevents dead_code warning (which CI treats as error via -D warnings)
when compiling without enterprise feature.

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

* chore: regenerate sqlx cache after merge with main

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

* fix: address review feedback on scheduled deletion

- Monitor: roll back transaction on any cleanup error so schedule rows
  survive for retry on next cycle (instead of best-effort then discard)
- Migration: add FK with ON DELETE CASCADE to job_delete_schedule.job_id
  to prevent orphan rows when jobs are deleted through other means
- Simplify bool-to-Option conversion with .then_some(true)

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

* refactor: stop setting delete_after_use alongside delete_after_secs

No mixed-version deployment scenario exists, so delete_after_secs alone
is sufficient. The backend's resolve_delete_after_secs handles
(None, Some(secs)) correctly without needing delete_after_use set.

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

* refactor: remove delete_after_use from public API surface

Remove delete_after_use from OpenAPI spec, API client, runtime client,
and workspace export. Only delete_after_secs is exposed going forward.

The field remains in Rust backend types with #[serde(skip_serializing)]
for backward-compatible deserialization of existing scripts/flows that
were saved with delete_after_use: true.

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

* chore: update ee-repo-ref to 1d4b7a31fc115d6aba8640f7cd3fd5a01abe6806

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

Previous ee-repo-ref: 9eba09a13b778caafc6ae65098b90e53c91984d3

New ee-repo-ref: 1d4b7a31fc115d6aba8640f7cd3fd5a01abe6806

Automated by sync-ee-ref workflow.

* fix: regenerate system prompts, remove unused import

- Regenerate auto-generated system prompts after openflow schema change
- Remove unused serde_json::json import in test file (CI -D warnings)

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

* fix: insert dummy v2_job row in schedule tests for FK constraint

The job_delete_schedule table has a FK to v2_job, so tests need a
real v2_job row before inserting into the schedule table.

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

* chore: trigger CI re-run

* fix: remove heavy flow integration tests to avoid CI worker contention

The flow integration tests spawn workers that compete for CPU with
the existing relock_skip tests under --test-threads=10, causing
consistent 60s timeouts in CI. Keep only the lightweight unit tests
and DB integration tests.

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

* fix: restore correct ee-repo-ref for our branch

The ref was overwritten to main's EE ref during a rebase. Restore to
our branch's EE commit that includes the autoscaling tuple fix.

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

* chore: retrigger CI on fresh runner

* fix: remove FK constraint from job_delete_schedule to unblock CI

The FK with ON DELETE CASCADE to v2_job may have caused performance
overhead during test DB setup (each sqlx::test creates a fresh DB
with all migrations). Remove the FK — orphan schedule rows are
harmlessly cleaned by the monitor.

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

* ee-ref

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-08 04:15:28 +00:00
Ruben Fiszelandrubenfiszel 9e6427d150 chore(main): release 1.679.0 (#8755)
* chore(main): release 1.679.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-07 21:14:29 +00:00
3d4f4c6c38 feat: Fork datatables (#8339)
* export_datatable_schema

* Propose to fork the datatable on ws fork

* dump datatable

* Dockerfile

* Fix import_datatable_dump

* datatable schema fork works!

* Option to copy both schema and data

* Datatable fork behavior

* nit ui

* use psql instead

* remove fork_datatable route

* feat: add fork_pg_database and export_pg_schema routes with DB Manager UI

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

* style: pluralize "schema" to "schemas" in DB Manager export/import UI

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

* feat: add import mode select (schema only vs schema + data) to DB Manager import

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

* Select schema or schema+data when important database

* fix: prepend $res: prefix to resource paths in DB Manager import/export

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

* style: dynamic import button label based on selected mode

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

* nits

* feat: add warning alert when schema+data import mode is selected

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

* nit hide on cloud hosted

* refactor: remove fork_behavior from datatable settings

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

* refactor: split CreateWorkspace into layout wrapper and CreateWorkspaceInner

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

* feat: instantiate CreateWorkspaceInner in globalForkModal

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

* nit icons

* Data table fork UI

* feat: pass per-datatable fork behaviors from UI to backend during workspace fork

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

* Fix fork overwriting all datatables

* UI nits

* custom instance db refactor

* custom instance db wizard btn for all in dropdown

* nit

* Delete custom instance database button

* Disable forking for resource datatables

* Big import buttons when db empty

* Revert "Disable forking for resource datatables"

This reverts commit 9561cc8fd4.

* feat: add non_diffable flag to resource table

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

* feat: add resource-type datatable fork with CREATE DATABASE

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

* feat: tag forked datatables with nonDiffable and forkedFrom

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

* feat: diff datatable and ducklake settings individually on workspace merge

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

* feat: skip non_diffable resources and datatables in workspace diff

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

* feat: default datatable fork behavior to keep_original

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

* fix: make grant permissions non-fatal in instance datatable fork

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

* fix: make datatable and ducklake diffs visible in workspace comparison

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

* refactor: remove datatable fork logic from workspace fork route

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

* fix: correct ahead/behind logic for datatable and ducklake diffs

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

* Revert "fix: correct ahead/behind logic for datatable and ducklake diffs"

This reverts commit 6b50884dc6.

* revert: remove datatable and ducklake settings diffing logic

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

* feat: add datatable clone UI with step-by-step confirmation modal

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

* refactor: extract datatable fork UI into ForkDatatableSection component

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

* nit

* fix: run datatable cloning before workspace fork creation

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

* nit disable fork admins

* nit fix switching workspace prematurely

* fix: use source workspace for forkPgDatabase calls during fork

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

* feat: update forked workspace datatable settings after fork creation

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

* feat: add forked_from field to DataTable and set it for instance forks

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

* nit onFinish

* fix: add forked_from to DataTableSettings OpenAPI schema

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

* feat: track datatable table DDL changes in workspace_diff

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

* Revert "feat: track datatable table DDL changes in workspace_diff"

This reverts commit 7526dd68b9.

* feat: add get_datatable_full_schema endpoint and snapshot schema on fork

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

* fix duplicate migration key

* fix: set forked_from on datatable config for both instance and resource types

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

* nits

* feat: drop forked databases on workspace deletion with confirmation UI

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

* refactor: extract drop_forked_datatable_databases from delete_workspace

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

* fix: cast pg char columns to text in FK schema query

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

* fix: show dbname instead of resource type in fork deletion modal

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

* ui nit

* refactor: extract drop_custom_instance_database into windmill-common

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

* feat: add datatable schema diff section to merge UI

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

* UI

* feat: add review drawer with YAML diff and SQL migration runner

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

* refactor: use Monaco DiffEditor for YAML diff in review drawer

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

* nit

* Revert "refactor: use Monaco DiffEditor for YAML diff in review drawer"

This reverts commit a86008ba4c.

* Revert "feat: add review drawer with YAML diff and SQL migration runner"

This reverts commit 0a0deb5ddb.

* feat: add review drawer with DiffEditor and SQL migration runner

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

* ui nits

* fix: show diff between forked_from schema and changed side

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

* fix: re-fetch target live schema after migration for correct baseline

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

* revert

* nit auto next

* feat: add confirmation modal before deploying migration to parent

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

* fix: handle missing columns/foreignKeys in schema conversion

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

* nits

* refactor: use temp file on disk for pg_dump instead of in-memory string

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

* Don't replace postgres dbname

* fix: add validation to drop_custom_instance_database and use source db for CREATE/DROP

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

* refactor: type DataTable.forked_from as DataTableForkedFrom struct

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

* refactor: simplify fork_pg_database to take source + target_dbname

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

* dead code

* feat: enforce schema_and_data admin-only and extract create_custom_instance_database

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

* refactor: rename fork_pg_database to import_pg_database with source/target/override params

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

* nit

* refactor: remove original_dbname/original_resource from forked_from, resolve from parent

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

* nit

* fix: resolve forked dbname from fork workspace when dropping resource databases

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

* nits

* fix: always clean up global_settings even if database doesn't exist

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

* fix: check datatable resource_type from config instead of URL prefix

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

* fix: wrap PG default value expressions in braces to prevent CAST quoting

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

* Revert "fix: wrap PG default value expressions in braces to prevent CAST quoting"

This reverts commit 77f5a2c4e8.

* refactor: reuse columnDefToTableEditorValuesColumn for default value handling

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

* fix: store raw API schema in forked_from to avoid double transformation

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

* Revert "fix: store raw API schema in forked_from to avoid double transformation"

This reverts commit e326197a20.

* Revert "refactor: reuse columnDefToTableEditorValuesColumn for default value handling"

This reverts commit bd8f071d9f.

* fix: validate dbname with strict regex to prevent SQL injection

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

* fix default value

* always validate dbname

* refactor: move get_datatable_full_schema structs and logic to query_builders.rs

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

* refactor: split import_pg_database into create_pg_database + import_pg_database

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

* refactor: extract drop_forked_datatable_databases into its own route

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

* fix: require admin when using $res: resource paths in import_pg_database

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

* fix: use UserDB for $res: resource access and restrict dbname creation

- resolve_pg_source_checked uses UserDB (row-level security) for $res: paths
- transform_json_unchecked is now pub(crate) to prevent misuse
- Non-superadmins can only create databases with wm_fork_ prefix
- datatable:// remains accessible to everyone

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

* fix: refuse to drop forked databases unless name starts with wm_fork_

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

* refactor: remove resolve_pg_source, use resolve_pg_source_checked everywhere

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

* Fix permissions

* sqlx prepare

* compilation nits

* sqlx prepare

* sqlx prepare

* wrong route syntax

* fix: allow workspace owner to edit datatable config for fork setup

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

* Revert "fix: allow workspace owner to edit datatable config for fork setup"

This reverts commit ab683e637b.

* refactor: move datatable fork setup into create_workspace_fork backend

Instead of updating datatable settings from the frontend after fork
creation (which required admin/owner access), pass forked_datatables
info to create_workspace_fork and handle it atomically in the same
transaction. Removes applyPostForkDatatableUpdates from frontend.

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

* fix: snapshot schema in backend during fork instead of frontend

The schema snapshot is now taken by the backend in apply_forked_datatable
via snapshot_datatable_schema, which connects to the parent workspace's
datatable and runs pg_get_full_schema. This removes the need for the
frontend to call getDatatableFullSchema and pass the schema through.

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

* fix: use get_resource_value_interpolated_internal for $res: to resolve $var: references

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

* nit

* sqlx prepare

* fix: add permission check to drop_forked_datatable_databases, validate dbnames, restrict temp file perms

- drop_forked_datatable_databases: same permission as delete_workspace
  (fork owner or super admin)
- validate_dbname on target_dbname_override and ForkedDatatableInfo.new_dbname
- Enforce wm_fork_ prefix on forked datatable new_dbname
- DumpFile: set /tmp/windmill/ to 0700 and create files with 0600

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

* nit CLI

* Rename to ws_specific

* sqlx prepare

* nit always validate dbname

* fix: include foreign keys in CREATE TABLE migration for added tables

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

* fix: detect nextval defaults and use SERIAL/BIGSERIAL types in CREATE TABLE

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

* Update frontend/src/lib/components/DBManagerDrawer.svelte

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update backend/windmill-common/src/lib.rs

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update backend/windmill-common/src/lib.rs

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix: sort foreign keys by constraint name for deterministic schema output

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

* sqlx prepare

* rename migration to update timestamp

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-07 21:03:06 +00:00
Ruben Fiszelandrubenfiszel 0bcbc8bd3c chore(main): release 1.678.0 (#8745)
* chore(main): release 1.678.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-07 20:25:49 +00:00
Diego Imbert 2413dbefe3 fix: Fix FlowTimeline duplicate key (#8754) 2026-04-07 20:13:30 +00:00
db55e8efb0 fix: remove span.enter() in dedicated worker to prevent tracing panic (#8749)
* [ee] fix: remove span.enter() in dedicated worker to prevent tracing panic

Update EE ref to include fix for dedicated worker tracing span panic that
caused benchmark failures after ~8000 jobs.

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

* chore: update ee-repo-ref to 86158dde674238fd94f925bdcd5155759e823ed6

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

Previous ee-repo-ref: a0480130c241d32b7e02951bfb5a03fdfc5737c8

New ee-repo-ref: 86158dde674238fd94f925bdcd5155759e823ed6

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-07 17:59:46 +00:00
wendrul 5125263859 add tracings for long debounce key errors (#8747)
* Add messages about debug key

* update ee repo ref

* undo b

* update merged ee repo ref
2026-04-07 17:59:35 +00:00
hugocasaandClaude Opus 4.6 d938625785 feat: add download all logs button for flow jobs (#8748)
* feat: add download all logs button for flow jobs

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

* fix: use recursive CTE to include all nested flow jobs in log download

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

* fix: start iteration index at 1 and interleave children with parents

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

* fix: distinguish branch vs loop iteration in log section headers

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

* fix: include flownode and singlestepflow kinds in branch/iteration labels

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

* fix: improve branch labels (branchone: default/1/2, branchall: 1/2)

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

* fix: resolve module types from flow_node table for nested structures

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

* fix: use full path in iteration/branch labels and show step kind name

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

* fix: show iteration index for simple module forloop optimized jobs

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

* fix: handle aiagent jobs as intermediate flow jobs with tool call children

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

* refactor: reuse existing get_logs_from_store/disk instead of duplicating

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

* sqlx

* sqlx

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:59:16 +00:00
wendrul eb32206940 cloud debounce keys potential 'value too long' error (#8750) 2026-04-07 17:57:48 +00:00
Ruben FiszelandClaude Opus 4.6 8b9523e03c fix: delete raw_script_temp rows before workspace deletion to avoid FK violation (#8752)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:34:18 +00:00
centdixandClaude Opus 4.5 2f7ba9edac fix: restore ai agent tool deletion (#8744)
* fix: restore ai agent tool deletion

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

* fix: reduce ai tool delete tree walks

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-07 12:32:57 +00:00
hugocasaandClaude Opus 4.6 208a597d59 feat: accept any content type on webhooks/http triggers with fallback (#8743)
* Revert "feat: restore bun for dedicated workers, fix dispatch & serialization, cross-workspace deps (#8645)"

This reverts commit 619ebb65ce.

* feat: accept any content type on webhooks/http triggers with fallback

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

* Reapply "feat: restore bun for dedicated workers, fix dispatch & serialization, cross-workspace deps (#8645)"

This reverts commit ee5420e401.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:14:24 +00:00
Ruben Fiszelandrubenfiszel c4be833bc0 chore(main): release 1.677.0 (#8737)
* chore(main): release 1.677.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-06 19:54:41 +00:00
Ruben FiszelandClaude Opus 4.6 b5c1eb3137 test: verify esbuild bundle output in app dev server for React and Svelte (#8741)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:29:07 +00:00
Ruben FiszelandClaude Opus 4.6 edfe074e98 fix: use runnable key for file naming in generate-metadata to prevent duplicate scripts in raw apps (#8740)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:08:07 +00:00
Ruben FiszelandClaude Opus 4.6 c09a4311fd fix: remove stale KMS openapi/description, restore stripped doc comments
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:19:03 +00:00
Ruben FiszelandClaude Opus 4.6 09bbc18bb7 feat: add AWS Secrets Manager as secret storage backend (Beta) (#8734)
* feat: add AWS KMS as secret backend (EE)

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

* feat: switch from AWS KMS to AWS Secrets Manager as secret backend

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

* test: add AWS Secrets Manager integration tests (requires LocalStack)

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

* feat: mark AWS Secrets Manager as beta

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

* fix: remove leftover KMS handler functions from api-settings

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

* chore: update ee-repo-ref to include AWS Secrets Manager EE impl

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

* fix: use full commit hash in ee-repo-ref.txt

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

* sqlx

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:17:15 +00:00
Ruben FiszelandClaude Opus 4.6 a78eb6e93d test: add integration test for public_app_by_custom_path endpoint (#8735)
Regression test for the missing labels column bug. Creates an app with
a custom path and anonymous execution mode, then fetches it via the
public custom path endpoint.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:17:00 +00:00
Ruben Fiszelandrubenfiszel af1f6506d2 chore(main): release 1.676.0 (#8730)
* chore(main): release 1.676.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-06 12:45:08 +00:00
Ruben Fiszel d2abc0d430 fix: fix custom urls not found 2026-04-06 12:33:21 +00:00
Ruben FiszelandClaude Opus 4.6 e32662169a feat: add path name autocomplete with ghost text and folder cycling (#8731)
* feat: add path name autocomplete with ghost text and folder cycling

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

* fix: filter out archived/deleted/draft paths from autocomplete

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

* feat: show subfolders immediately after Tab-navigating into a folder

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

* feat: remove 2-char minimum for suggestions, hide placeholder when suggestions show

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

* feat: show LCP ghost text for multiple matches, Enter accepts it for Tab cycling

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

* fix: suppress Path.svelte Enter dispatch when ghost text is accepted

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

* fix: compute LCP inline in Enter handler to avoid reactive timing issues

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

* fix: Enter picks the first folder and navigates into it

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

* fix: Enter picks the currently Tab-highlighted folder, not always the first

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

* style: remove stray blank lines in applyCycleOrComplete

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

* fix: review fixes — $bindable default, openapi cache description, non-null assertion

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

* chore: add sqlx query cache for path_autocomplete

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 03:44:21 +00:00
Ruben FiszelandClaude Opus 4.5 c721fac466 perf: add partial index for expired cache resource cleanup (#8728)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-05 18:33:03 +00:00
Ruben Fiszelandrubenfiszel 7eaaf3021a chore(main): release 1.675.1 (#8727)
* chore(main): release 1.675.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-05 17:01:17 +00:00
Ruben FiszelandClaude Opus 4.5 f703fba1ef fix: log cleanup scans S3 orphans and works cross-server (#8729)
* fix: log cleanup scans S3 orphans and works cross-server

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

* fix: don't skip service log orphan scan when job retention is disabled

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

* fix: time-based heartbeat + flag partial folder sizes on list errors

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

* refactor: move background_task module from common to api-settings

Only log_cleanup and storage_usage use it today, both in windmill-api-settings.
Keeping it in the consumer crate narrows the blast radius; if workers or
indexer later need cross-server lease+progress coordination they can move it
back to common then.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-05 16:56:46 +00:00
Ruben FiszelandClaude Opus 4.6 eae46a21a9 perf: add indexes for cleanup deletes on concurrency_key and autoscaling_event (#8726)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:10:30 -04:00
Ruben Fiszelandrubenfiszel 31df861ee2 chore(main): release 1.675.0 (#8716)
* chore(main): release 1.675.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-05 14:18:34 +00:00
Ruben Fiszel e605bc4e07 sqlx 2026-04-05 14:15:24 +00:00
Yoaquim Cintrón 7bf6ac2b69 fix: enrich OTEL spans with job_kind, trigger_kind, trigger, created_by, and script_hash (#8718)
Add five new attributes to the `job` and `job_postprocessing` tracing spans
so that OTEL-consuming backends (Sentry, Honeycomb, Datadog, etc.) can
filter and group telemetry by how a job was triggered and what type it is.

New span attributes:
- `job_kind`     — Script, Flow, AppScript, AIAgent, Preview, etc.
- `created_by`   — the user or system identity that queued the job
- `trigger_kind` — schedule, webhook, kafka, http, sqs, etc.
- `trigger`      — the schedule/trigger path (when applicable)
- `runnable_id`  — the id of the runnable that ran

Also adds `JobKind::as_str()` for a consistent lowercase string
representation, following the same pattern as `ScriptLang::as_str()`.

Existing attributes (job_id, workspace_id, script_path, language, tag,
flow_step_id, parent_job, root_job) are unchanged.

Note: the EE `full_job` span in `otel_ee.rs` and the log records emitted
by `job_logger_ee.rs` would also benefit from these attributes. This PR
covers only the public-repo spans; a follow-up EE change would propagate
the same fields to logs and the full_job span.
2026-04-05 14:11:22 +00:00
Ruben FiszelandClaude Opus 4.6 01e39d9cd1 fix: split DB health endpoint and add slow query controls (#8725)
Split the DB health page into independent panes so fast pg_catalog-based
diagnostics render without waiting for the slower job table scans, and
enrich the slow queries view with server-side sort, reset, and better
setup guidance.

Backend:
- Split /api/db_health into two endpoints: fast panes (database_size,
  connection_pool, table_maintenance, slow_queries, datatables) and
  /jobs (job_retention, large_results with scan_limit).
- Add GET /api/db_health/slow_queries?sort=total|mean|calls for
  server-side sorting of pg_stat_statements queries (sort whitelisted
  via enum, SQL-injection safe).
- Add POST /api/db_health/slow_queries/reset to call
  pg_stat_statements_reset().
- Return stats_reset timestamp from pg_stat_statements_info (PG 14+).
- Bump slow queries to top 50 sorted by total_exec_time (was top 10 by
  mean_exec_time, which misses high-cumulative-load queries).
- Truncate slow queries to 500 chars (was 200).
- Filter table_maintenance to tables with >= 1000 total tuples.

Frontend (DbHealth.svelte):
- Two tabs (Overview / Jobs) with auto-refresh on selection.
- Refresh buttons right-aligned in both tabs; Jobs tab keeps the
  scan_limit selector on the left.
- Job Retention & Large Results always render, with "Click Refresh to
  load" placeholders when no data yet.
- Slow queries table: clickable column headers for server-side sort,
  click a row to toggle the full query text.
- Reset stats button with confirmation dialog, displays "Stats since"
  timestamp for before/after comparison workflow.
- When pg_stat_statements is not installed, show numbered setup
  instructions with copyable SQL snippets.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:00:44 +00:00
Ruben Fiszel 02d0ee9198 feat: add object storage usage view and manual log cleanup (#8724) 2026-04-05 13:10:48 +00:00
Ruben FiszelandClaude Opus 4.6 dd39c110a8 fix: add admin check to count_completed_jobs_detail and document query builder SQL safety (#8722)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:06:14 +00:00
Ruben FiszelandClaude Opus 4.6 342defecd2 block adding/inviting members to admins workspace (#8721)
* fix: block adding/inviting members to admins workspace on CE

The admins workspace is reserved for superadmins only. On CE (non-enterprise),
prevent adding or inviting users to it via both API and UI.

Backend: add #[cfg(not(feature = "enterprise"))] guards to invite_user and
add_user endpoints that reject requests targeting the admins workspace.

Frontend: show an info alert on the admins workspace members page and hide
the add/invite/auto-add buttons.

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

* fix: use derived variable for admins workspace alert consistency

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:06:14 +00:00
Ruben FiszelandClaude Opus 4.5 2b865c0694 fix: allow private AI base URLs in ai_proxy integration test (#8715)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-03 21:26:24 -04:00
Ruben Fiszelandrubenfiszel 7c3bd67639 chore(main): release 1.674.2 (#8714)
* chore(main): release 1.674.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-04 01:00:33 +00:00
Ruben FiszelandClaude Opus 4.6 ff8e39c69b fix: enforce RLS on $var: resolution in AI proxy (GHSA-jwg4-v3cj-rvfm) (#8713)
* fix: enforce RLS on $var: resolution in AI proxy to prevent secret exfiltration

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

* chore: update sqlx prepared queries

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:55:57 +00:00
Ruben FiszelandClaude Opus 4.5 f394e674f2 fix: SSRF via X-Resource-Path header in AI proxy endpoint (#8712)
* fix: validate AI provider base URLs to prevent SSRF via X-Resource-Path header

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

* fix: improve SSRF error message to mention ALLOW_PRIVATE_AI_BASE_URLS env var

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-04 00:43:08 +00:00
Ruben Fiszelandrubenfiszel 6bb5cac0c4 chore(main): release 1.674.1 (#8711)
* chore(main): release 1.674.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-04 00:05:05 +00:00
Ruben Fiszel 3e9a9f44bb nit windows tests 2026-04-04 00:04:49 +00:00
Ruben FiszelandClaude Opus 4.6 aff95c33b2 fix: create pg connection for cloud-hosted jobs instead of panicking (#8710)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:00:22 +00:00
Ruben Fiszelandrubenfiszel 653356011c chore(main): release 1.674.0 (#8693)
* chore(main): release 1.674.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-03 22:52:49 +00:00
fda68a72e5 feat: Support .ducklake() and .datatable() in agent workers (#8697)
* Update clients to check for agent workers

* fixes

* typescript uses 127.0.0.1

* Refresh system prompts

* fix: check both localhost and 127.0.0.1 in workerHasInternalServer detection

Both Python and TypeScript clients now check for both hostnames to avoid
silent breakage if BASE_INTERNAL_URL uses one or the other. Also adds
return type annotation to the Python method.

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>

* refresh system prompts

* nit localhost regex boundary

* fix: use provider.language instead of undefined bare language in sqlUtils

The language variable was referenced as a bare identifier in the fetch
calls, resolving to undefined at runtime instead of reading from
provider.language.

Co-Authored-By: Claude Opus 4.6 (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>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:34:34 +00:00
Ruben FiszelandClaude Opus 4.6 ef74a1bb4d add type predicates to .filter() in sqlUtils for strict TypeScript (#8709)
The discriminated union type from values.map() wasn't being narrowed by
.filter((info) => !info.raw), causing info.argNum to be typed as
number | undefined instead of number.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:27:38 +00:00
Ruben Fiszel 1a0f580f3d nit 2026-04-03 22:21:31 +00:00
Ruben FiszelandClaude Opus 4.6 6d58d1a74d fix: pipeline DISCARD ALL with first query on cached pg connections (#8707)
* perf: pipeline DISCARD ALL with first query on cached pg connections

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

* perf: use RESET ALL instead of DISCARD ALL for lighter session reset

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

* test: add integration test for pg session reset on cached connections

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

* fix: release MutexGuard before caching so pg connection cache actually works

The old code shadowed the MutexGuard variable without dropping it, so
try_lock() in the post-query caching path always failed — connection
caching was effectively dead code. Restructure to explicitly drop the
guard before connecting.

Also adds a CACHE_HITS counter and clear_pg_cache() helper so the
integration test can verify the cached-connection path is exercised.

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

* test: add single-worker session isolation test for SET ROLE + search_path

Pushes 3 jobs into the queue before starting the worker so a single
worker processes them all sequentially (matching production). Verifies
SET ROLE and SET search_path do not leak between jobs.

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

* fix: add RESET ROLE to session reset (RESET ALL does not undo SET ROLE)

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

* fix: use DISCARD ALL for full session reset and retry on stale connections

- Switch from pipelined RESET ROLE; RESET ALL to eager DISCARD ALL when
  validating cached connections. This resets everything: role, GUCs,
  prepared statements, temp tables, advisory locks, LISTEN registrations.
- DISCARD ALL also serves as a health check: if it fails, the stale
  connection is discarded and a fresh one is created transparently.
- Extract new_pg_connection() helper to avoid duplicating the connect +
  spawn-connection-task logic.

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

* test: add 100-job single-worker cache stress test

Runs 100 varied PG jobs (plain SELECTs, SET ROLE, SET search_path,
multi-statement) through one worker. Verifies all succeed, 99 hit the
cache, and no session state leaks between jobs.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:16:48 +00:00
Diego ImbertandClaude Opus 4.6 ce290f68db feat: sql.raw in Typescript client (#8706)
* feat: detect sql.raw() in TS parser and tag queries with has_raw_interpolation

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

* feat: filter out sql.raw queries from type-checking and preparation

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

* feat: implement sql.raw() for inline raw SQL fragments in template literals

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

* refactor: split sqlProviderImpl into provider interface + shared builder

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

* fix ts client compilation

* update asset parser

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:20:37 +00:00
dcd615fdc3 feat: add Azure Key Vault as secret storage backend (#8704)
* feat: add --main flag to write_latest_ee_ref.sh to point to latest EE main

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

* feat: add Azure Key Vault as secret storage backend (EE)

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

* chore: update ee-repo-ref.txt to azure-key-vault-support branch

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

* feat: add token auth, insecure TLS for emulator, and integration tests

Adds optional `token` field to AzureKeyVaultSettings for direct Bearer
auth (bypasses OAuth2), enables self-signed cert acceptance in token mode,
and includes 4 integration tests against the Azure KV emulator.

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

* chore: update ee-repo-ref.txt

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

* fix: handle Azure KV soft-delete and emulator quirks

- Purge soft-deleted secrets after delete to allow name reuse
- Retry set_secret on 409 Conflict (purge stale soft-deleted secret)
- Accept self-signed certs when using static token (emulator mode)
- Work around emulator version-ordering bug in CRUD test

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

* chore: update ee-repo-ref.txt

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

* chore: update ee-repo-ref to 47b0d9d5d163efdab1e145ee012bdb2eb1373b78

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

Previous ee-repo-ref: d432d78bda151d611d8065162de7c1b7edce92e9

New ee-repo-ref: 47b0d9d5d163efdab1e145ee012bdb2eb1373b78

Automated by sync-ee-ref workflow.

* fix: accept token OR client_secret in Azure KV validation, add token UI field

- isAzureKvConfigValid() now accepts either client_secret or token
- Added token input field to the Azure KV config form for emulator/dev use

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-03 21:02:36 +00:00
Ruben Fiszel 18eb6e0df7 point to latest otel 2026-04-03 19:59:30 +00:00
Ruben FiszelandClaude Opus 4.6 adc9fe722d fix: gate relock_skip tests on private feature and update ee-repo-ref (#8703)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:58:54 +00:00
0aea49f960 feat: add http/protobuf support for OTEL exporters (#8702)
* feat: add --main flag to write_latest_ee_ref.sh to point to latest EE main

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

* [ee] feat: add http/protobuf support for OTEL exporters

Add http-proto and reqwest-client features to opentelemetry-otlp to
enable HTTP/protobuf transport as an alternative to gRPC.

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

* chore: update ee-repo-ref.txt

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

* feat: expose OTEL protocol selector in instance settings UI

Replace the hardcoded "gRPC" label with a dropdown allowing users to
select between grpc (default) and http/protobuf.

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

* chore: update ee-repo-ref to 681b725781611510ed3040c00e8f9b8497d6feda

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

Previous ee-repo-ref: 50051ded8183e662a9e932d87d17258501f3e944

New ee-repo-ref: 681b725781611510ed3040c00e8f9b8497d6feda

Automated by sync-ee-ref workflow.

* fix: remove reqwest-client feature to avoid conflict with default reqwest-blocking-client

The opentelemetry-otlp crate only activates the reqwest-client HTTP client
when reqwest-blocking-client is NOT also enabled. Since the default features
include reqwest-blocking-client, having both resulted in no HTTP client being
created. The default reqwest-blocking-client works correctly.

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

* chore: update ee-repo-ref.txt

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

* iterate

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-03 19:30:25 +00:00
ba214709b9 fix: add secretKeyRef support for jwt_secret and rsa_keys (#8698)
* feat: add secretKeyRef support for jwt_secret and extra fields (rsa_keys)

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

* chore: update ee-repo-ref.txt

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

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

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

Previous ee-repo-ref: ade3bb76f8e0a6e658313b54c7180577fc9efc37

New ee-repo-ref: 2c24cf597fdf8c4dccd483f1f1e5c49eb42ef3a3

Automated by sync-ee-ref workflow.

* test: replace unit tests with integration tests for jwt_secret and rsa_keys

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

* chore: update ee-repo-ref.txt

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-03 18:17:27 +00:00
bffa61e33f fix: dedicated worker dispatch, cross-workspace deps, UI improvements (#8689)
* feat: restore bun as default runtime for dedicated workers

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

* chore: add context comment for bun dedicated worker nodejs migration

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

* fix: dedicated worker dispatch for flows + add E2E tests

- Add workspace_id prefix to dedicated worker map lookup keys
- Update ee-repo-ref for dedicated worker path handling fix
- Add spawn_test_worker_dedicated/in_test_worker_dedicated test helpers
- Add 6 E2E tests for dedicated workers:
  - test_dedicated_flow_rawscript (regression for "Script not found" bug)
  - test_dedicated_flow_workspace_script
  - test_dedicated_flow_multiple_steps
  - test_dedicated_standalone_script
  - test_dedicated_runner_group
  - test_dedicated_flow_runners
- Add dedicated_flows.sql fixture with scripts, flows, and worker config

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

* fix: always run dependency job for dedicated worker scripts

When a script with dedicated_worker=true is deployed with a pre-computed
lock (e.g. via wmill sync push), no dependency job was created, so the
dedicated worker never detected the update and kept running the old version.

Now dedicated worker scripts always generate a dependency job regardless
of whether a lock is provided. The dependency job runs on the dedicated
worker and triggers a restart so it picks up the new script version.

Fixes #8638

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

* fix: use serial_test for dedicated worker tests to avoid WORKER_CONFIG races

Dedicated worker tests need non-default worker tags in the global
WORKER_CONFIG. When run in parallel (CI uses --test-threads=10),
multiple tests clobber each other's config. Use #[serial] to ensure
dedicated worker tests run sequentially.

Also load worker config from DB via load_worker_config() instead of
manually setting WORKER_CONFIG fields, ensuring consistency with the
monitor's reload path.

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

* fix: nodejs dedicated worker script_path shadowing + add multi-language E2E tests

Fix script_path shadowing in bun_executor nodejs branch where the wrapper
file path was passed to handle_dedicated_process instead of the logical
path, causing "Script not found" for all //nodejs dedicated workers.

Add E2E tests for dedicated flows in all supported languages:
- test_dedicated_flow_deno
- test_dedicated_flow_python
- test_dedicated_flow_bunnative (V8 PrewarmedIsolate path)
- test_dedicated_flow_bun_nodejs (//nodejs annotation)

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

* fix: simplify dedicated worker dispatch + add serialization and E2E tests

- Unified lookup: always use {workspace}:{runnable_path} for dedicated
  worker dispatch, replacing the flow_step_id iteration approach
- Added serialization_semaphore parameter to executor start_worker fns
- Added E2E tests: cross-workspace isolation, conflicting flow step IDs,
  preprocessor on dedicated worker
- Added workspace field to RunJob for cross-workspace test support

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

* feat: cross-workspace workspace dependencies on workers page

Add two new instance-level endpoints to the configs router:
- GET /configs/list_all_workspace_dependencies
- GET /configs/list_all_dedicated_with_deps

Both require devops role and return data across all workspaces,
enabling the workers page to show a consistent view of which
workspace dependencies exist regardless of which workspace the
user is browsing.

Update DedicatedWorkersSelector to use the new cross-workspace
endpoints with fallback to per-workspace calls for non-devops users.

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

* chore: update ee-repo-ref to include dedicated worker lookup simplification

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

* chore: use branch name for ee-repo-ref (CI can't fetch by SHA from non-default branch)

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

* Update ee-repo-ref.txt with new reference

* sqlx

* fix: revert serialization semaphore, multi-workspace picker, dep conflict warnings

- Remove serialization_semaphore from executor start_worker signatures
- Remove serialization test and fixtures
- Fix DedicatedWorkersSelector to preserve tags from other workspaces
  when toggling in the picker
- Track workspace deps per-workspace for conflict detection
- Show warning when dep exists in another workspace but not the script's
- Group runner groups per-workspace to prevent cross-workspace merging
- Add workspace to dep badge link URL

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

* chore: update ee-repo-ref

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

* fix: simplify exec protocol — execd: for single-script, exec: for runner groups

Add execd:/execd_preprocess: commands to bun/deno/python wrappers for
single-script dedicated workers (no path needed). Runner groups keep
exec:/exec_preprocess: with path for multi-script disambiguation.

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

* test: add unit tests for execd:/exec: wrapper protocol

Verify generate_multi_script_wrapper produces both execd: (single-script)
and exec: (runner group) protocol handlers, including preprocessor variants.

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

* Update commit reference in ee-repo-ref.txt

* fix: remove beta badge from squash loop, keep tooltip

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

* fix: update protocol tests to use execd: for single-script wrappers

Deno and bun single-script protocol tests now send execd:{args} instead
of exec:{path}:{args}, matching the updated wrapper protocol. Multi-script
(runner group) tests continue to use exec:{path}:{args}.

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

* fix: remove unused TEST_SCRIPT_PATH in deno protocol tests

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

* fix: review feedback — down migration, push_as workspace, UI improvements

- Use regexp_replace in down migration for positional accuracy
- Fix push_as() to use self.workspace_id instead of hardcoded value
- Remove per-workspace API fallbacks, use cross-workspace endpoints only
- Skip devops-only API calls when user is not devops (disabled prop)
- Fix duplicate key error for cross-workspace runner groups
- Add workspace to RunnerGroup for unique keying
- Reuse tagRow snippet for standalone items with expand/collapse
- Fix picker alignment: remove empty column for non-expandable items

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

* test: comprehensive dedicated worker test coverage, fix Python execd_preprocess

- Add Python execd_preprocess: handler (was missing for single-script dedicated workers)
- Add 10 E2E tests: flow+standalone conflict, mixed lang fallback, unsupported lang
  flow runners, python runner group, bun/python/deno/bunnative preprocessors,
  runner group preprocessors, branchone flow
- Add 4 Python unit tests for execd:/execd_preprocess: protocol
- Update EE ref

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

* chore: update ee-repo-ref

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

* fix: review feedback — migration escaping, deno try/catch, loadRunnables guard

- Down migration: use E'...' so \n matches actual newlines
- Up migration: anchor regex with ^ to avoid mid-content matches
- Deno execd_preprocess: move JSON.parse inside try/catch
- DedicatedWorkersSelector: skip devops-only API calls when disabled

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

* chore: update ee-repo-ref

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

* test: add dedicated worker relative import tests for bun and python

Verifies that build_loader's CURRENT_PATH correctly resolves workspace-
relative imports when running on a dedicated worker subprocess.

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

* fix: dedicated worker dispatch for nested flow structures (branches/loops)

- Add extract_flow_root() to strip nesting segments from runnable_path
- Dispatch uses flow_root/flow_step_id for nested paths, runnable_path
  for flat paths — deterministic, O(1)
- Fix assert_ran_on_dedicated_worker to BFS all descendants
- Fix python mode labels (python vs python3 for runner groups)
- Add tests: simple forloop, multi-step forloop, whileloop, branchall,
  nested branch-in-loop, mixed lang fallback, unsupported lang runners

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

* chore: fix ee-repo-ref SHA

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

* fix: hide picker and skip API calls for read-only users, hide empty runner badge

- Hide "Add more scripts/flows" section when disabled (read-only)
- Skip per-runnable API calls (getScriptByPath, getFlowByPath) for
  disabled users — just show path info
- Hide "0 runners" badge on flows with no eligible steps

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

* chore: update ee-repo-ref to 9422b189762ae27edfc346541ae668a4ad728325

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

Previous ee-repo-ref: 4c6ba214bfc23fff05d1dc3200ac59e650af3f4f

New ee-repo-ref: 9422b189762ae27edfc346541ae668a4ad728325

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-03 17:50:07 +00:00
Ruben FiszelandClaude Opus 4.6 27ca417201 fix: resolve schedule update deadlock (#8701)
* feat: add --main flag to write_latest_ee_ref.sh to point to latest EE main

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

* fix: resolve schedule update deadlock by fixing lock ordering in edit_schedule

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:44:10 +00:00
Ruben FiszelandClaude Opus 4.6 c4c9ef5fd7 feat: add optional labels to scripts, flows, apps, schedules, triggers (#8609)
* feat: add optional labels to scripts, flows, apps, raw apps, schedules, and triggers

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

* fix: update sqlx cache, make labels optional in openapi, regenerate system prompts

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

* feat: add minimal labels input UI to script, flow, and schedule editors

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

* fix: reduce gap between summary and labels input

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

* feat: add labels to script/flow detail pages and summary/path popover

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

* fix: move labels inside SummaryPathDisplay trigger for clickable area, reduce gap

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

* fix: display labels inline to the right of summary, not below

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

* fix: increase gap between summary and labels

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

* feat: add labels to resources/variables, make labels nullable, add home page label filter badges

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

* feat: add labels to workspace export/import, resources, variables + test coverage

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

* fix: make migration idempotent, regenerate sqlx cache after merge

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

* fix: pass labels in script create and flow create/update API calls

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

* feat: add labels input UI to resource and variable editors

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

* fix: remove negative margin from LabelsInput to prevent overlap

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

* fix: add top and left margin to LabelsInput for better spacing

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

* fix: reduce left margin on LabelsInput

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

* fix: widen label input to w-32

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

* fix: use inline-flex so LabelsInput doesn't stretch full width

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

* fix: remove flex-wrap so label input stays on same line as badges

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

* feat: add label filter presets to resources, variables, and schedules search

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

* fix: use max-w-32 on label input to prevent stretching

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

* fix: pull labels closer to summary with negative top margin

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

* fix: increase negative margin to pull labels even closer to summary

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

* fix: pass labels in schedule create/update API calls

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

* fix: use COALESCE to preserve existing labels when not provided in schedule/flow update

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

* fix: add labels to CreateResource, EditResource, CreateVariable, EditVariable in OpenAPI spec

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

* feat: display label badges on resource and variable list pages

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

* feat: display label badges on schedule and all trigger list pages

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

* feat: add folder and label presets to schedules search filter

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

* fix: apply user_folders_only filter on all workspaces including admins

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

* feat: add label presets to resources and variables search filters

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

* fix: derive folder presets from loaded items, not all workspace folders

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

* fix: add label query parameter to resource and variable list endpoints in OpenAPI

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

* feat: display label filter badges inline with folder filters on home page

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

* Revert "feat: display label filter badges inline with folder filters on home page"

This reverts commit 6767a50aa6.

* feat: support comma-separated label filters (allowMultiple) in all list endpoints

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

* fix: append label presets with comma for allowMultiple filters instead of duplicating key

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

* fix: hide label presets that are already in the comma-separated filter value

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

* fix: replace unsafe manual SQL ARRAY construction with parameterized queries, add labels to ScriptWDraft

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

* fix: complete down migration, add labels to Resource/Variable OpenAPI schemas, remove type cast, add label length validation

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

* fix: add labels field to Schedule test fixture

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

* fix: add labels field to Rust client struct constructions

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

* fix: regenerate sqlx cache with --all-features for EE builds

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

* chore: regenerate sqlx cache and package-lock after merge with main

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

* fix: squash two migrations into one, use IF NOT EXISTS for idempotency

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

* fix: track label changes in SummaryPathDisplay to enable save button

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

* fix: use JSON string comparison for label dirty tracking in popover

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

* fix: navigate to script by path after save from popover to load new version

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

* fix: update initialLabels after save so subsequent label changes enable save again

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

* fix: use onchange callback for label dirty tracking instead of derived comparison

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

* fix: reload script by path after label save to fetch new version

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

* feat: propagate script/flow labels to jobs at push time

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

* feat: show script/flow labels on runs page, merge with wm_labels for completed jobs

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

* fix: change job labels type from JSONB to text[], show labels on job detail page, fix type mismatch

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

* fix: add labels to QueuedJob struct, fix get_job queries to return v2_job.labels

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

* fix: replace +Label text with icon only

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

* feat: add tag icon before labels on job detail page

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

* fix: move tag icon inside badge on job detail page

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

* fix: use blue badge with tag icon in RunBadges, remove duplicate labels from JobDetailHeader

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

* fix: set icon position to left so tag icon renders in badge

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

* fix: render Tag icon inline in badge children instead of via icon prop

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

* fix: retry icon prop with small badge and position left

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

* feat: add hover tooltip showing "Label: X" on job label badges

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

* feat: include v2_job.labels in runs page label filter and broad search

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

* chore: regenerate sqlx cache and system prompts after merge with main

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

* fix: add labels to EE JobPayload constructions, regenerate sqlx cache with --all-features

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

* fix: regenerate sqlx cache CE-only (without EE symlinks that cause conflicts)

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

* fix: update remaining wm_labels JSONB queries to use text[] merge expression

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

* fix: simplify job labels to just read v2_job.labels (wm_labels already merged at completion)

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

* fix: consistent label badge spacing with gap-0.5 wrapper and px-0.5 on badges

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

* fix: add labels: None to test utils JobPayload construction

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

* fix: add labels to all test fixture JobPayload/NewFlow/EditApp constructions, regenerate sqlx cache

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

* fix: fix vertical content shift by fixing container and input height to h-5

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

* fix: npm_check errors - unused imports, combinedItems order, flow.labels type, badge px-1 padding

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

* fix: remove unused FolderService imports, fix label badge alignment in RunBadges

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

* fix: restore deleted service imports in variables page, remove empty loadFolders

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

* chore: trigger CI with updated ee-repo-ref

* chore: update ee-repo-ref to merged EE companion PR

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

* chore: trigger fresh CI run for updated ee-repo-ref

* fix: match label badge size with other badges in RunBadges using {large} prop

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

* fix: remove icon from RunBadges label badge to fix vertical alignment

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

* fix: shorten "Job kind" to "Kind" in run badges

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

* fix: add small inline tag icon (10px, -mt-px) to label badge without disrupting height

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

* fix: add "Label: X" hover tooltip to all label badges, show hidden labels on +N hover

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

* feat: add tag icon and "Label: X" tooltip to home page label filter badges

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

* fix: show LabelsInput even when path is hidden in ResourceEditor

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

* feat: add labels input to new resource creation drawer (AppConnectInner)

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

* iterate

* fix: add LabelsInput to all resource creation steps in AppConnectInner

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

* fix: reduce LabelsInput top margin from -mt-3 to -mt-1

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

* fix: increase negative margin to -mt-2 for tighter spacing

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

* fix: split the difference with -mt-1.5

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

* fix: adjust to -mt-1 for label spacing

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

* fix: per-site label spacing via class prop instead of global negative margin

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

* feat: make label badges clickable to toggle label filter on resources, variables, schedules

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

* fix: use proper array indexOf for label filter toggle, set undefined correctly on removal

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

* fix: use delete instead of undefined to properly clear label filter

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

* feat: add /labels/list endpoint and autocomplete dropdown to LabelsInput

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

* fix: use inline preventDefault for Svelte 5 event handling

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

* feat: add "Create new" option in label autocomplete, regenerate sqlx cache with update_sqlx.sh

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

* feat: add GIN indexes on labels column for all 16 tables

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

* fix: remove CONCURRENTLY from GIN index creation in migration

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

* test: add comprehensive label coverage for pull, edit, removal across all item types

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

* fix: simplify job label filters to only use v2_job.labels, remove wm_labels back-compat

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

* test: add integration tests for job label propagation, display, and filtering

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

* fix: address PR review findings — missing labels in fetch_script_for_update, app rename, escape key bug

- Add `labels` to SELECT in `fetch_script_for_update` to prevent lost labels on script clone
- Pass `labels` in app branch of `moveRenameManager.ts` so app renames preserve labels
- Clear `inputValue` before `adding = false` in LabelsInput escape handler to prevent accidental label add via onblur
- Fix `test_job_label_filter` to complete jobs via SQL (label filtering only works on completed jobs)
- Add `test_wm_labels_from_result_merged_with_static_labels` integration test using Bun

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:39:32 +00:00
centdixandClaude Opus 4.5 b960598431 fix: hide deprecated cli metadata commands (#8699)
* fix: hide deprecated cli metadata commands

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

* fix: simplify generate-metadata guidance

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-03 15:38:01 +00:00
centdixandClaude Opus 4.5 f234df97ec fix: support raw app deployment history (#8657)
* fix: support raw app deployment history

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

* fix: refresh deployment history diffs

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

* fix: preserve deployment history preview context

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

* fix: limit deployment history to diffs

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

* chore: remove unused history backend hook

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-03 14:56:56 +00:00
Diego Imbert a3073ad824 fix: debounce S3 proxy logs (#8694)
* Debounce S3 proxy logs

* missing workspace id

* nit perf

* nit

* prevent DOS

* handle 4xx/5xx statuses

* fix magic numbers
2026-04-03 13:46:09 +00:00
Alexander PetricandClaude Opus 4.6 ceaa613522 chore: add .playwright-mcp/ to .gitignore (#8696)
Prevent Playwright MCP console logs from being accidentally committed.
Addresses GitHub security advisory for leaked credentials in log files.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:39:10 +00:00
Ruben FiszelandClaude Opus 4.6 0317d5891c feat: add powershell common parameters support (#8683)
* feat: add powershell common parameters support (-Verbose, -Debug, -ErrorAction, -WhatIf)

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

* feat: add powershell common params to script editor test panel

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

* fix: detect CmdletBinding from code instead of schema in script editor

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

* fix: ignore commented-out CmdletBinding in powershell detection

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

* fix: use preference variables for -Verbose/-Debug instead of CLI args

Verbose/Debug output goes to PowerShell stream 4/5 which isn't captured
by the 2>&1 redirect. Setting $VerbosePreference/$DebugPreference in the
wrapper scope propagates to child scripts and output flows through the
host to stderr, which Windmill captures as logs.

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

* fix: use *>&1 to capture all powershell streams including verbose/debug

The previous 2>&1 only captured error stream. Verbose (stream 4) and
debug (stream 5) output was silently lost. Using *>&1 redirects all
streams to success stream so they flow through Tee-Object into logs.

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

* fix: use targeted stream redirects (4>&1 5>&1 2>&1) instead of *>&1

*>&1 breaks $PSCmdlet.ShouldProcess() by redirecting internal streams.
Only redirect verbose (4), debug (5), and error (2) to success stream.

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

* fix: revert to 2>&1 redirect — stream 4/5 redirects break powershell

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

* fix: use 4>&1 5>&1 for verbose/debug capture, remove WhatIf support

Stream 4/5 redirects capture verbose/debug in the pipeline. WhatIf is
removed because $PSCmdlet.ShouldProcess() doesn't work when scripts
are invoked through Windmill's wrapper.

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

* fix: redirect verbose/debug to files to keep result pipeline clean

Verbose (4) and debug (5) streams are redirected to separate log files
during script execution, then output via Write-Host after the script
completes. This keeps them out of the Tee-Object pipeline (used for
result extraction) while still showing them in the job logs.

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

* fix: output verbose/debug to stderr via Console.Error for log capture

Write-Host goes to stdout which gets mixed with result output and
truncated by OSS log threshold. Using [Console]::Error.WriteLine()
writes to stderr which Windmill captures separately as logs, with
VERBOSE:/DEBUG: prefixes for clarity.

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

* fix: redirect script output to file only, send verbose/debug to stdout

The OSS log storage has a 9KB threshold. Previously, Tee-Object sent
the full JSON result to both stdout (logs) and the pipe file, eating
the log budget. Now script output goes only to the pipe file (> $pipe),
and only verbose/debug messages go to stdout for the log viewer.

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

* fix: preserve original Tee-Object behavior, append verbose/debug after

Keep the original wrapper behavior (Tee-Object to stdout + pipe file).
Only add 4>verbose.log 5>debug.log to capture those streams, and
output them at the end of logs.

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

* fix: inject preference vars into main.ps1 instead of CLI args

Passing -Verbose/-Debug as CLI args causes PowerShell module loading
to emit verbose noise. Instead, inject $VerbosePreference/$DebugPreference
inside main.ps1's try block so they only affect user code. Stream 4/5
are still redirected to files in the wrapper for log output.

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

* fix: restore common param toggles from previous job args on Run Again

Extract _wm_ps_* keys from loaded args and initialize the toggle
states in PowerShellCommonParams. Also strip them from main args
so they don't appear as unknown schema form inputs.

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

* feat: show active common param badges when section is collapsed

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

* fix: inject ErrorAction as preference variable instead of CLI arg

-ErrorAction as a CLI arg only affects the caller, not the script's
internal error handling. Setting $ErrorActionPreference inside main.ps1
correctly overrides the default 'Stop' behavior for the user's code.

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

* fix: ensure full backward compatibility with existing powershell scripts

- Only filter common param names when [CmdletBinding()] is present
  (without it, $Verbose etc. are regular user-defined parameters)
- Only add 4>verbose.log 5>debug.log and log output lines when common
  params are actually enabled — original wrapper is unchanged otherwise

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

* refactor: lighter styling for common params section

Replaced heavy Section component with a subtle inline chevron toggle
labeled "Common parameters". Smaller text, secondary color, indented
options. Badges still show when collapsed.

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

* fix: rename section to CmdletBinding parameters

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

* fix: add ..Default::default() to windmill-parser-r (new parser from main)

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

* fix: missing comma in graphql parser test + merge main

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

* fix: add missing commas before ..Default::default() in parser tests

Merge from main brought test constructors with formatting issues
from the original automated script (missing comma between last field
and ..Default::default()).

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

* fix: restore comment markers in nu parser test that script broke

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

* fix: address PR review — ignore commented CmdletBinding, clear stale params

1. Parser: strip comment lines before detecting [CmdletBinding()] to
   avoid false positives from commented-out attributes
2. RunForm: always assign psCommonParams (not just when non-empty) so
   stale settings from a previous run don't leak into later runs

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:03:22 +00:00
Ruben FiszelandClaude Opus 4.6 7485a5db04 remove dead legacyBehaviour param from metadata functions (#8695)
The legacyBehaviour parameter on generateFlowLockInternal,
generateAppLocksInternal, and generateScriptMetadataInternal was never
passed as true — the tree parameter alone determines the code path.
Replace `!legacyBehaviour && tree` with just `tree` and remove the
param from all call sites. getRawWorkspaceDependencies keeps its
legacyBehaviour param since it has a real effect there.

Also adds 6 integration tests covering generate-metadata lockfile
generation and idempotency for scripts, flows, and apps.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:01:34 +00:00
0cfa462c37 fix: optimize S3 proxy performance (#8685)
* perf: re-export GetOptions and GetRange from object_store

Needed by S3 proxy to use get_opts with range for single-request
range fetches instead of HEAD + get_range.

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

* Avoid logging S3 proxy requests as info

* Revert "Avoid logging S3 proxy requests as info"

This reverts commit b6359a7a03.

* Don't log s3 proxy

* Revert "Don't log s3 proxy"

This reverts commit 2b21ee3c78.

* Update duckdb

* AUTOMATIC_MIGRATION for ducklake

* ee repo ref

* wrong comment

* chore: update ee-repo-ref to 41b0d1cb312919109407640fc4bd7060cfe0e107

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

Previous ee-repo-ref: 9b97a1c563365006657c4c6cde6e7df31c5173c3

New ee-repo-ref: 41b0d1cb312919109407640fc4bd7060cfe0e107

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-04-03 11:53:10 +00:00
Kain 6656b46f10 fix: align script push metadata warning with generated locks (#8690) 2026-04-03 11:41:31 +00:00
5b7fa63bf1 feat: add application-level heartbeat support for websocket triggers (#8686)
* feat: add application-level heartbeat support for websocket triggers

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

* Update SQLx metadata

* chore: regenerate auto-generated schema and skill files

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

* fix: handle missing heartbeat channel gracefully, fix TextInput props

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

* refactor: only clone heartbeat sender when heartbeat is configured

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-03 11:31:08 +00:00
cdf3c29664 fix: use pre-aggregated stats for telemetry job usage queries (#8688)
* fix: use pre-aggregated worker_group_job_stats for telemetry job usage queries

Replace slow v2_job_completed JOIN v2_job scans with reads from the
pre-aggregated worker_group_job_stats table for the schedule-only
job_usage (48h) and daily_job_usage queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to telemetry-query-timeout branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 94567b204a5536ec3dc7591830c58c5bdc1d8381

This commit updates the EE repository reference after PR #506 was merged in windmill-ee-private.

Previous ee-repo-ref: da62a74e965a079d95eea6510f2ac7fc004cdccc

New ee-repo-ref: 94567b204a5536ec3dc7591830c58c5bdc1d8381

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-03 11:30:33 +00:00
Ruben Fiszelandrubenfiszel 81ab777cbb chore(main): release 1.673.0 (#8660)
* chore(main): release 1.673.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-04-02 19:41:12 -04:00
hugocasa 61a867f086 Revert "feat: restore bun for dedicated workers, fix dispatch & serialization, cross-workspace deps (#8645)" (#8687)
This reverts commit 619ebb65ce.
2026-04-02 23:09:38 +00:00
Diego Imbert 8581a3300d fix: Run typed pg queries in a single protocol conversation (#8679)
* Run typed pg queries in one protocol conversation

* Update pg queries for db manager to use explicit type syntax

* Unused import
2026-04-02 22:00:38 +00:00
Ruben FiszelandClaude Opus 4.6 ff5fa9f64f fix: poll for preview results to avoid undici headers timeout (#8682)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:55:46 +00:00
Ruben FiszelandClaude Opus 4.6 55e8a5cff1 fix: add HMAC signature verification to Slack interactive callback endpoint (#8611)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:55:23 +00:00
1a39bd538d add opt-in SMTP click tracking disable for email links (#8665)
* feat: add opt-in SMTP click tracking disable for email links

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt for email clicktracking branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt after simplification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: exclude trailing commas from URL regex in clicktracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 57dd88faa3b0b354f813385cf3f6a34eca54a4a1

This commit updates the EE repository reference after PR #504 was merged in windmill-ee-private.

Previous ee-repo-ref: 5cf901db7fb0ea169b09564372e444f28e23ac3a

New ee-repo-ref: 57dd88faa3b0b354f813385cf3f6a34eca54a4a1

Automated by sync-ee-ref workflow.

* chore: update ee-repo-ref.txt to include dedicated worker fixes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-02 20:44:08 +00:00
Ruben FiszelandClaude Opus 4.6 1049c1026b fix Windows compilation errors in MemoryLimitedChild (#8684)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:33:23 +00:00
hugocasaandClaude Opus 4.6 619ebb65ce feat: restore bun for dedicated workers, fix dispatch & serialization, cross-workspace deps (#8645)
* feat: restore bun as default runtime for dedicated workers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add context comment for bun dedicated worker nodejs migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: dedicated worker dispatch for flows + add E2E tests

- Add workspace_id prefix to dedicated worker map lookup keys
- Update ee-repo-ref for dedicated worker path handling fix
- Add spawn_test_worker_dedicated/in_test_worker_dedicated test helpers
- Add 6 E2E tests for dedicated workers:
  - test_dedicated_flow_rawscript (regression for "Script not found" bug)
  - test_dedicated_flow_workspace_script
  - test_dedicated_flow_multiple_steps
  - test_dedicated_standalone_script
  - test_dedicated_runner_group
  - test_dedicated_flow_runners
- Add dedicated_flows.sql fixture with scripts, flows, and worker config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: always run dependency job for dedicated worker scripts

When a script with dedicated_worker=true is deployed with a pre-computed
lock (e.g. via wmill sync push), no dependency job was created, so the
dedicated worker never detected the update and kept running the old version.

Now dedicated worker scripts always generate a dependency job regardless
of whether a lock is provided. The dependency job runs on the dedicated
worker and triggers a restart so it picks up the new script version.

Fixes #8638

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use serial_test for dedicated worker tests to avoid WORKER_CONFIG races

Dedicated worker tests need non-default worker tags in the global
WORKER_CONFIG. When run in parallel (CI uses --test-threads=10),
multiple tests clobber each other's config. Use #[serial] to ensure
dedicated worker tests run sequentially.

Also load worker config from DB via load_worker_config() instead of
manually setting WORKER_CONFIG fields, ensuring consistency with the
monitor's reload path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: nodejs dedicated worker script_path shadowing + add multi-language E2E tests

Fix script_path shadowing in bun_executor nodejs branch where the wrapper
file path was passed to handle_dedicated_process instead of the logical
path, causing "Script not found" for all //nodejs dedicated workers.

Add E2E tests for dedicated flows in all supported languages:
- test_dedicated_flow_deno
- test_dedicated_flow_python
- test_dedicated_flow_bunnative (V8 PrewarmedIsolate path)
- test_dedicated_flow_bun_nodejs (//nodejs annotation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify dedicated worker dispatch + add serialization and E2E tests

- Unified lookup: always use {workspace}:{runnable_path} for dedicated
  worker dispatch, replacing the flow_step_id iteration approach
- Added serialization_semaphore parameter to executor start_worker fns
- Added E2E tests: cross-workspace isolation, conflicting flow step IDs,
  preprocessor on dedicated worker
- Added workspace field to RunJob for cross-workspace test support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: cross-workspace workspace dependencies on workers page

Add two new instance-level endpoints to the configs router:
- GET /configs/list_all_workspace_dependencies
- GET /configs/list_all_dedicated_with_deps

Both require devops role and return data across all workspaces,
enabling the workers page to show a consistent view of which
workspace dependencies exist regardless of which workspace the
user is browsing.

Update DedicatedWorkersSelector to use the new cross-workspace
endpoints with fallback to per-workspace calls for non-devops users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to include dedicated worker lookup simplification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: use branch name for ee-repo-ref (CI can't fetch by SHA from non-default branch)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update ee-repo-ref.txt with new reference

* sqlx

* fix: revert serialization semaphore, multi-workspace picker, dep conflict warnings

- Remove serialization_semaphore from executor start_worker signatures
- Remove serialization test and fixtures
- Fix DedicatedWorkersSelector to preserve tags from other workspaces
  when toggling in the picker
- Track workspace deps per-workspace for conflict detection
- Show warning when dep exists in another workspace but not the script's
- Group runner groups per-workspace to prevent cross-workspace merging
- Add workspace to dep badge link URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify exec protocol — execd: for single-script, exec: for runner groups

Add execd:/execd_preprocess: commands to bun/deno/python wrappers for
single-script dedicated workers (no path needed). Runner groups keep
exec:/exec_preprocess: with path for multi-script disambiguation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add unit tests for execd:/exec: wrapper protocol

Verify generate_multi_script_wrapper produces both execd: (single-script)
and exec: (runner group) protocol handlers, including preprocessor variants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update commit reference in ee-repo-ref.txt

* fix: remove beta badge from squash loop, keep tooltip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update protocol tests to use execd: for single-script wrappers

Deno and bun single-script protocol tests now send execd:{args} instead
of exec:{path}:{args}, matching the updated wrapper protocol. Multi-script
(runner group) tests continue to use exec:{path}:{args}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove unused TEST_SCRIPT_PATH in deno protocol tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:37:02 +00:00
Ruben FiszelandClaude Opus 4.6 d2d6810db9 feat: add LIMIT_WINDOWS_TO_1CU env var for Windows worker memory limits (#8681)
* feat: add LIMIT_WINDOWS_TO_1CU env var for Windows worker memory limits

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address CI review — stricter env var parsing and SAFETY comment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:31:07 +00:00
Ruben FiszelandClaude Opus 4.6 39af1b75af fix: skip generate-metadata confirmation prompt in non-interactive CI (#8678)
* fix: generate-metadata non-interactive CI and misleading log path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add hash consistency tests for workspace deps staleness checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:28:39 +00:00
Ruben FiszelandClaude Opus 4.6 d569e9e29c fix: resolve race condition where flow sync push reverts to stale version (#8673)
* fix: resolve race condition where flow sync push reverts to stale version

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add sqlx offline cache for new queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add version guard before writing to prevent TOCTOU race

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:17:37 +00:00
Ruben FiszelandClaude Opus 4.6 381011a4a8 fix: pass selected language to AI agent when generating flow scripts (#8680)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:06:22 +00:00
hugocasaandClaude Opus 4.6 f0437eba19 feat: add endpoint to restart workers in a worker group (#8659)
* feat: add endpoint to restart workers in a worker group

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate sqlx query cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing modules field to RawCode in tests and regenerate sqlx cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* update sqlx

* fix: use require_devops_role for restart worker group endpoint

Matches the permission level of the clean cache endpoint (update_config),
allowing both superadmin and devops role users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback for restart worker group

- Fix OpenAPI description to say "devops role" instead of "superadmin"
- Add dispatch('reload') after restart to refresh worker list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only dispatch reload on successful restart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:36:48 +00:00
Ruben FiszelandClaude Opus 4.6 d8edaad99c improve bun bundle error message for syntax errors (#8677)
* fix: improve bun bundle error message for syntax errors like unclosed brackets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert: remove error hint from node_builder.ts wrapper catch blocks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:19:46 +00:00
Ruben FiszelandClaude Opus 4.6 7fd0bf974d fix: respect disabled fields in JSON input mode (#8663)
* fix: respect disabled fields in JSON input mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard against undefined default in disabled field enforcement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show toast when disabled fields are reset to defaults on run

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:13:12 +00:00
6a5cfbc159 feat: add Entra ID (Azure Workload Identity) database auth (#8526)
* feat: add Entra ID (Azure Workload Identity) support for database auth

Add support for Azure Workload Identity to authenticate to Azure Database
for PostgreSQL using short-lived Entra ID tokens. Mirrors the existing
AWS IAM RDS auth pattern.

- Extract shared DatabaseParams to db_params.rs for reuse across providers
- Add DatabaseUrl::EntraId variant with token refresh
- Detect "entraid" magic password in DATABASE_URL
- Unified background refresh task for both IAM RDS and Entra ID
- Support sovereign clouds via AZURE_AUTHORITY_HOST env var

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore needs_refresh() check in background token refresh task

The unified refresh task was missing the needs_refresh() gate, causing
it to refresh tokens every 10 seconds instead of only when near expiry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt for Entra ID branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move entraid env var reads inside cfg(private) block

Fixes unused variable warnings in OSS and EE-without-private builds
where -D warnings is enabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 0e001bab643e449b3310b0692dd3598ee0902ecc

This commit updates the EE repository reference after PR #483 was merged in windmill-ee-private.

Previous ee-repo-ref: 44199013ed0c96680672e718f35124aa34a5d010

New ee-repo-ref: 0e001bab643e449b3310b0692dd3598ee0902ecc

Automated by sync-ee-ref workflow.

* refactor: add needs_refresh() and refresh_if_needed() to DatabaseUrl

Simplify duplicated refresh logic per Claude review suggestion.
Background task and get_database_url() now use shared methods
instead of matching on each variant individually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-02 16:00:33 +02:00
Ruben FiszelandClaude Opus 4.5 8c3c97f7a6 fix: sanitize MCP tool schemas for JSON Schema draft 2020-12 compliance (#8666)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-02 13:53:16 +00:00
1310 changed files with 112868 additions and 22299 deletions
+265
View File
@@ -0,0 +1,265 @@
---
name: adding-a-trigger
description: Checklist for adding a new TriggerCrud-based trigger type to Windmill (Azure, GCP, Kafka, etc.). Use when wiring a new trigger kind across backend, frontend, CLI, and capture infrastructure.
---
# Skill: Adding a New Trigger Type
Use this skill when adding a trigger kind that implements `TriggerCrud` (Kafka, GCP, Azure, MQTT, SQS, NATS, Postgres, Email…). For native triggers (Nextcloud, Google Drive — things wired through `windmill-native-triggers`), use the `native-trigger` skill instead.
The goal of this doc is to enumerate every file that needs to change. Missing any one of them leads to silent regressions: sync drops the trigger, capture button does nothing, workspace forks lose it, sidebar counters undercount. Follow the checklist top-to-bottom — each section is independent enough to be validated on its own.
Throughout this doc, substitute `{kind}` for the new trigger kind (`azure`, `kafka`, …), `{Kind}` for PascalCase (`Azure`, `Kafka`), `{KIND}` for SCREAMING (`AZURE`, `KAFKA`).
## Reference implementations
- **GCP** — closest analogue to Azure. Has push + pull, OIDC auth, ARM-like resource paths, capture handler. Grep for `gcp_trigger` / `GcpTrigger`.
- **Kafka** — simpler (pull-only, streaming). Good for trivial integrations.
- **Azure** — most recently added (2026). Shared-secret push auth, Event Grid namespaces + basic topics, ARM resource discovery, Namespace-pull data-plane. Grep for `azure_trigger` / `AzureTrigger`.
## 1. Database migration
Create a migration: `cargo sqlx migrate add -r add_{kind}_trigger` from `backend/`. Never write timestamps manually.
The `up.sql` usually defines:
- An optional enum type (e.g. `AZURE_MODE`) if the trigger has sub-kinds
- The `{kind}_trigger` table with at minimum these columns (mirrored from kafka/gcp):
- primary: `(workspace_id, path)`
- `script_path`, `is_flow`, `enabled`, `mode`, `permissioned_as`, `edited_by`, `email`
- `edited_at`, `error`, `server_id`, `last_server_ping`
- `error_handler_path`, `error_handler_args jsonb`, `retry jsonb`
- trigger-specific fields
- Indexes on foreign keys + any frequently-filtered columns
- Foreign key to `workspace`
Down migration drops the table and any enum types.
## 2. Backend crate (`windmill-trigger-{kind}`)
Create a new crate under `backend/windmill-trigger-{kind}/` with:
- `Cargo.toml`: features `enterprise`, `private` if EE, standard deps
- `src/lib.rs`: `pub use mod_ee::*;` behind `#[cfg(all(feature = "enterprise", feature = "private"))]`
- `src/mod_ee.rs`: core types + helpers
- `src/handler_ee.rs`: `TriggerCrud` impl + route handlers
- `src/listener_ee.rs`: (only if streaming/pull-based) `Listener` trait impl
Required in `mod_ee.rs`:
- `{Kind}Config` struct (persisted shape, `FromRow`)
- `{Kind}ConfigRequest` struct (what API receives — usually similar to Config but with validation fields)
- `{Kind}Trigger` unit struct (implements the traits)
- `impl TriggerJobArgs for {Kind}Trigger` — sets `TRIGGER_KIND`, `Payload`, `v1_payload_fn`
Required in `handler_ee.rs`:
- `#[async_trait] impl TriggerCrud for {Kind}Trigger` with:
- `type Trigger = Trigger<{Kind}Config>`
- `type TriggerConfigRequest = {Kind}ConfigRequest`
- `const ROUTE_PREFIX: &'static str = "/{kind}_triggers";`
- `const TABLE_NAME`, `ADDITIONAL_SELECT_FIELDS`
- `get_deployed_object`, `validate_config`, `create_trigger`, `update_trigger`, `delete_trigger`, `test_connection`
- `additional_routes` (optional — mount extra endpoints for things like ARM resource listing, topic discovery)
Register the crate in `backend/Cargo.toml` as a workspace member and as a dep of `windmill-api` behind the feature flag.
## 3. Wire into `windmill-api` (feature-gated everywhere)
**`backend/windmill-api/src/triggers/handler.rs`** — mount the trigger crate:
```rust
#[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))]
{
use crate::triggers::{kind}::{Kind}Trigger;
router = router.nest({Kind}Trigger::ROUTE_PREFIX, complete_trigger_routes({Kind}Trigger));
}
```
**`backend/windmill-api/src/triggers/{kind}/mod.rs`** — re-export the crate:
```rust
pub use windmill_trigger_{kind}::*;
```
**`backend/windmill-api/src/lib.rs`** — if the trigger receives inbound pushes, add a webhook route:
```rust
.nest("/{kind}/w/{workspace_id}", {
#[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))]
{ triggers::{kind}::handler_oss::{kind}_push_route_handler() }
#[cfg(not(...))]
{ Router::new() }
})
```
## 4. `TriggerKind` enum (`backend/windmill-types/src/triggers.rs`)
Already has slots for most triggers but verify your variant exists:
- Add `{Kind}` to the `TriggerKind` enum
- Add match arm in `to_key()`
- Add match arm in `from_str`
- Add match arm in `JobTriggerKind` (if jobs need kind tagging)
## 5. OpenAPI (`backend/windmill-api/openapi.yaml`)
This file is huge and the single most-forgotten place. Add:
- `/w/{workspace}/{kind}_triggers/create` + `/update/{path}` + `/delete/{path}` + `/get/{path}` + `/list` + `/exists/{path}` + `/setmode/{path}` + `/test` paths (mirror gcp section)
- Any `additional_routes` your handler exposes (resource discovery, etc.)
- Schemas: `{Kind}Trigger`, `{Kind}TriggerData`, `{Kind}Mode` (if enum), `{Kind}DeliveryConfig`, helper request/response types
- Add `{kind}` to `CaptureTriggerKind` enum
- Add `{kind}_used: boolean` to the `UsedTriggers` response schema
Regenerate frontend client: `npm run generate-backend-client` from `frontend/`.
## 6. `UsedTriggers` + workspace export
**`backend/windmill-api-workspaces/src/workspaces.rs`** — add `{kind}_used: bool` to the `UsedTriggers` struct and add an `EXISTS(SELECT 1 FROM {kind}_trigger …)` to the `get_used_triggers` query.
**`backend/windmill-api/src/workspaces_export.rs`** — add export block mirroring gcp's (export lists all triggers, serializes them to YAML/JSON).
## 6.5 Hardcoded trigger-kind arrays (silent-failure hotspots)
Several files keep **hardcoded arrays** of trigger kind strings. Miss one and ACL checks / user offboarding / trash drop your kind:
- **`backend/windmill-api-groups/src/granular_acls.rs`** — `KINDS: [&str; N]`. **Increment N** (the compile error is cryptic otherwise). Controls which kinds accept granular ACL operations.
- **`backend/windmill-api-users/src/users.rs`** (`extra_perms_tables`) — which tables get `extra_perms` entries cleaned when a user is deleted.
- **`backend/windmill-api/src/offboarding.rs`** — three separate arrays (enumeration, fork-copy, and delete paths). **All three** need the new kind.
- **`backend/windmill-api/src/trash.rs`** — `valid_tables` for the trash / restore API.
- **`backend/windmill-git-sync/src/lib.rs`** — add a test assertion for `DeployedObject::{Kind}Trigger.get_kind() == "{kind}_trigger"` (the `get_kind` match arm itself lives in the enum impl — already required by the Rust compiler).
- **`backend/windmill-api-auth/src/scopes.rs`** — add the `{Kind}Triggers` variant to `ScopeDomain` enum + `as_str` match + `from_str` match. Required for the OAuth/token system to recognise `{kind}_triggers:read|write` scopes.
- **`backend/windmill-api/src/token.rs`** (`build_trigger_scope_domains``TRIGGER_DOMAINS`) — add `("{kind}_triggers", "{Kind display name}")` so the CreateToken UI's scope selector surfaces the `read` / `write` checkboxes.
**OpenAPI enums** to extend (do NOT forget — generated client will allow it but server rejects as 400):
- `CaptureTriggerKind` enum
- Three `kind` enums under `/w/{workspace}/acls/{get,add,remove}/{kind}/{path}` (yes, same list repeated three times)
After editing any of these, run a full `cargo check` with your feature flag + `gcp_trigger` + other core flags — the `KINDS: [&str; N]` length mismatch only surfaces when the crate compiles.
## 7. Capture infrastructure (`backend/windmill-api/src/capture.rs`)
If the trigger supports push delivery, it also needs a capture endpoint so users can test it:
- `{Kind}TriggerConfig` struct (gated by feature flags)
- `TriggerConfig::{Kind}` variant
- `set_{kind}_trigger_config` function (creates the subscription/equivalent pointing at the capture URL — use your `manage_{kind}_subscription` helper with `trigger_mode=false`)
- Both real + no-op versions behind feature gates
- `TriggerKind::{Kind} => set_{kind}_trigger_config(...)` arm in `set_config`
- `{kind}_payload` async handler — validates auth (if any), processes payload, calls `insert_capture_payload`
- Route: `.route("/{kind}/{runnable_kind}/{*path}", post({kind}_payload))` inside `workspaced_unauthed_service` — and expand the surrounding `#[cfg(any(...))]` to include your feature flag
## 8. CLI (`cli/`) — easy to miss, breaks sync silently
Check all of these:
**`cli/src/types.ts`:**
- Add `"{kind}"` to `TRIGGER_TYPES` array
- Add `"{kind}_trigger"` to `getTypeStrFromPath` return union
- Add match case in `getTypeStrFromPath`'s `typeEnding ===` chain
- Add `pushTrigger("{kind}", ...)` branch in `pushObj`
**`cli/src/commands/trigger/trigger.ts`:**
- Import `{Kind}Trigger` type
- Add `{kind}: {Kind}Trigger` to the `Trigger` type map
- Add `{kind}: wmill.get{Kind}Trigger`, `update{Kind}Trigger`, `create{Kind}Trigger` to each function map
- Add `{kind}: { ... }` template to `triggerTemplates`
- Add `list{Kind}Triggers` call + spread in the `list` aggregation
- Update `--kind` option descriptions to mention the new kind
**`cli/src/commands/sync/sync.ts`:**
- Add `path.endsWith(".{kind}_trigger" + ext)` in the file-type filter
- Add `typ == "{kind}_trigger"` in `getTypeOrder`
- Add `"{kind}_trigger"` to the delete-suffix regex (~line 3092)
- Add a `case "{kind}_trigger"` in the delete switch
**`cli/src/guidance/skills.ts`** — **DO NOT EDIT DIRECTLY**. It's auto-generated by `system_prompts/generate.py`. Instead:
- Edit `system_prompts/utils.py` → append `('{Kind}Trigger', '{kind}_trigger')` to the `SCHEMA_MAPPINGS['triggers']` list (this is the master list — the one in `generate.py` is duplicated and `utils.py` wins)
- Then run `python3 system_prompts/generate.py` — it regenerates `cli/src/guidance/skills.ts` with the schema extracted from `backend/windmill-api/openapi.yaml`
- Commit the regenerated file
## 9. Frontend — editor + drawer
Under `frontend/src/lib/components/triggers/{kind}/`:
- `{Kind}TriggerPanel.svelte` — the tile shown in the triggers listing
- `{Kind}TriggerEditor.svelte` — outer drawer wrapper
- `{Kind}TriggerEditorInner.svelte` — state + business logic; must expose:
- `openEdit(path, isFlow, defaultValues?)` method
- `isEditor` prop, `onConfigChange` + `onCaptureConfigChange` callbacks
- `get{Kind}Config()` + `get{Kind}CaptureConfig()` helpers
- `captureConfig = $derived.by(untrack(() => isEditor) ? get{Kind}CaptureConfig : () => ({}))`
- `$effect(() => { const args = [captureConfig, isValid] as const; untrack(() => onCaptureConfigChange?.(...args)) })`
- `{Kind}TriggerEditorConfigSection.svelte` — form fields; use design-system components (`TextInput`, `Select`, `Toggle`, `ToggleButtonGroup`), never raw `<input>`
- `{Kind}Capture.svelte` — capture panel; wraps `CaptureSection` with `captureType="{kind}"`
- `utils.ts``requestBody` builders and any trigger-type-specific helpers
## 10. Frontend — global integration
Easy to miss:
- **`frontend/src/lib/components/triggers.ts`** — add `'{kind}'` to the `TriggerKind` union
- **`frontend/src/lib/components/triggers/CaptureWrapper.svelte`**:
- Import `{Kind}Capture`
- Add to `isStreamingCapture()` array (streaming = pull-style; push-style is typically `false`)
- Add `{:else if captureType === '{kind}'}` branch with the `<{Kind}Capture>` render
- **`frontend/src/lib/components/sidebar/SidebarContent.svelte`** — import the icon, add the nav entry
- **`frontend/src/lib/components/sidebar/OperatorMenu.svelte`** — add the operator-mode entry
- **`frontend/src/routes/(root)/(logged)/+layout.svelte`** — destructure `{kind}_used` from `/get_used_triggers` response, push `'{kind}'` into `usedKinds`
- **`frontend/src/lib/components/search/GlobalSearchModal.svelte`** — import icon, add "Go to {Kind} ..." entry
- **`frontend/src/lib/components/offboarding-utils.ts`** — add mappings `{kind}_trigger: '{kind}_triggers'` and `{kind}_trigger: '{kind} trigger'`
- **`frontend/src/lib/components/icons/{Kind}Icon.svelte`** — single-path SVG, `fill={color ?? 'currentColor'}`, `size` prop default 16 (match existing icons — don't hardcode colors, don't use `width`/`height` props)
- **`frontend/src/routes/(root)/(logged)/{kind}_triggers/+page.svelte`** — listing page (mirror `gcp_triggers/+page.svelte` for push+pull, `kafka_triggers` for pure streaming)
- **`frontend/src/lib/components/CompareWorkspaces.svelte`** — workspace fork / compare tool. Needs: service import, editor import, `{kind}Editor` `$state`, `case '{kind}'` in `openTriggerDetails()`, entry in `triggerServices` object (list/delete/normalize), and `<{Kind}TriggerEditor bind:this={{kind}Editor} />` in the template
## 10.5 AI system prompts (`system_prompts/`)
- **`system_prompts/utils.py`** — append `('{Kind}Trigger', '{kind}_trigger')` to `SCHEMA_MAPPINGS['triggers']` (master list used by code generation + CLI skills)
- **`system_prompts/generate.py`** — also has a duplicated `schema_types` list (~line 903) for the AI `triggers` skill content. Add `('{Kind}Trigger', '{kind}_trigger')` there too
- **`system_prompts/generate.py`** `schema_names` (~line 1192) — add `'{Kind}Trigger'` (add `'New{Kind}Trigger'` only if the OpenAPI declares one; GCP and Azure don't)
- Run `python3 system_prompts/generate.py` — this rewrites `cli/src/guidance/skills.ts` and all `auto-generated/` docs. Commit the regenerated files
## 11. Validation
Run all of these before declaring done:
```bash
# Backend
cd backend
cargo check --features enterprise,{kind}_trigger,private # minimal
cargo check --features enterprise,azure_trigger,private,gcp_trigger,http_trigger,mqtt_trigger,postgres_trigger,sqs_trigger,kafka,nats,smtp,websocket # full
# SQLx offline data (never run `cargo sqlx prepare` directly — use the wrapper)
./update_sqlx.sh
# Frontend
cd frontend
npm run generate-backend-client
npm run check:fast
```
Smoke test in the UI: create a trigger, save, check it appears in sidebar + search, delete, re-create via CLI `wmill sync`.
## 12. Common pitfalls
- **Forgetting feature gates in `workspaced_unauthed_service()`** — the surrounding `#[cfg(any(...))]` expression must include your feature flag, not just the inner `#[cfg]` on the route
- **`.route(path, ...).route(path, ...)` with same path and different methods** — older axum replaced; use `.route(path, post(h1).options(h2))` to chain methods on the same `MethodRouter`
- **`on:event` directives** — legacy Svelte 4, no-op in runes mode. Use callback props (`onSelected`, `onConfigChange`)
- **`$bindable(default_value)` on optional props** — banned by project CLAUDE.md. Use `$bindable()` + `$derived(prop ?? default)` instead
- **CORS layer intercepting OPTIONS** — tower-http CorsLayer short-circuits OPTIONS before reaching your handler. For server-to-server webhook endpoints, drop the CORS layer entirely (CORS is browser-only)
- **DeliveryAttributeMappings / custom headers for auth** — prefer HMAC or sha256-hashed shared secrets over opaque JWTs when the provider doesn't support signed tokens natively. Store only the hash; regenerate secret on every save
- **ARM / API resource-listing cascades** — if the trigger's resource type is deep (Azure: subscription → RG → namespace → topic), offer dropdowns in the UI populated from the provider's APIs using the user's credential resource
- **Clearing stale selections on dependency change** — when a dropdown's underlying data reloads (e.g., user changes SP or edition), clear selections that no longer match the new list
- **Workspace-scoped tag compatibility** — if the trigger has tags, verify forked workspaces handle them (see commit `0773b5bc85` for a historical fix)
## 13. EE file split
If the trigger is enterprise-only, the code lives in `windmill-ee-private__worktrees/.../windmill-trigger-{kind}/src/*_ee.rs` and is symlinked into the OSS tree. The `windmill-ee-private__worktrees/` directory holds the real files; changes propagate via symlinks. See `docs/enterprise.md` for the workflow.
## 14. Final checklist before PR
- [ ] Migration up/down tested (revert + re-apply)
- [ ] `./update_sqlx.sh` committed the updated `.sqlx/` offline data
- [ ] `cargo check` passes with your feature flag + with all trigger features
- [ ] `npm run check:fast` passes
- [ ] Trigger visible in sidebar with correct icon weight (not oversized/colored — use `currentColor`)
- [ ] Create, edit, delete flow all work in the UI
- [ ] Capture button works (if push-capable)
- [ ] Trigger appears in `/get_used_triggers` → sidebar pulse
- [ ] `wmill sync pull` + `wmill sync push` both round-trip the trigger
- [ ] `wmill trigger list` includes it
- [ ] OpenAPI schemas are complete (no `null` in generated types)
+12 -1
View File
@@ -607,7 +607,18 @@ In `frontend/src/lib/components/triggers/TriggersEditor.svelte`:
Add your service to the `nativeTriggerServices` map in `deleteDeployedTrigger()`. Native triggers use `NativeTriggerService.deleteNativeTrigger({ workspace, serviceName, externalId })` instead of the standard `path`-based delete.
### Step 17: Update OpenAPI Spec and Regenerate Types
### Step 17: Update `getUsedTriggers` for Sidebar Visibility
The sidebar (`frontend/src/lib/components/sidebar/SidebarContent.svelte`) shows native-trigger links only if `$usedTriggerKinds` includes the service — without this, your trigger page will never appear in the nav bar even when triggers exist.
1. **Backend** — add `{service}_used: bool` to the `UsedTriggers` struct and SELECT in `backend/windmill-api-workspaces/src/workspaces.rs::get_used_triggers()`:
```rust
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = '{service}'::native_trigger_service) AS "{service}_used!"
```
2. **OpenAPI** — add `{service}_used: boolean` to the response schema for `GET /w/{workspace}/workspaces/used_triggers` (under both `properties` and `required`).
3. **Layout** — in `frontend/src/routes/(root)/(logged)/+layout.svelte::loadUsedTriggerKinds()`, destructure `{service}_used` and push `'{service}'` to `usedKinds`.
### Step 18: Update OpenAPI Spec and Regenerate Types
Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then:
+4
View File
@@ -0,0 +1,4 @@
#:schema https://developers.openai.com/codex/config-schema.json
[mcp_servers.svelte]
url = "https://mcp.svelte.dev/mcp"
+6
View File
@@ -20,4 +20,10 @@ sed -i '' -e "/^wmill =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
sed -i '' -E "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" ${root_dirpath}/backend/Cargo.lock
# windmill-parser-wasm is its own workspace (excluded from the backend workspace
# because of nightly-only cargo-features), so its version lives in
# [workspace.package] and its Cargo.lock is not regenerated by the backend step.
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/parsers/windmill-parser-wasm/Cargo.toml
sed -i '' -E "s/(name = \"windmill[^\"]*\"\nversion = )\"[^\"]*\"/\\1\"$VERSION\"/g" ${root_dirpath}/backend/parsers/windmill-parser-wasm/Cargo.lock
cd ${root_dirpath}/frontend && npm i --package-lock-only
+6
View File
@@ -21,4 +21,10 @@ sed -i -e "/^wmill =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
sed -i -zE "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" ${root_dirpath}/backend/Cargo.lock
# windmill-parser-wasm is its own workspace (excluded from the backend workspace
# because of nightly-only cargo-features), so its version lives in
# [workspace.package] and its Cargo.lock is not regenerated by the backend step.
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/parsers/windmill-parser-wasm/Cargo.toml
sed -i -zE "s/(name = \"windmill[^\"]*\"\nversion = )\"[^\"]*\"/\\1\"$VERSION\"/g" ${root_dirpath}/backend/parsers/windmill-parser-wasm/Cargo.lock
cd ${root_dirpath}/frontend && npm i --package-lock-only --ignore-scripts
@@ -145,6 +145,10 @@ jobs:
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
# 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.
RUST_MIN_STACK: 4194304
VCPKGRS_DYNAMIC: 1
OPENSSL_DIR: ${{ env.VCPKG_INSTALLATION_ROOT }}\installed\x64-windows-static
DENO_PATH: ${{ steps.runtime-paths.outputs.DENO_PATH }}
+5
View File
@@ -244,6 +244,11 @@ jobs:
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
# Tests' poll-time stack frames (deep nested async fn chains in
# debug builds) reach ~1.8MB, leaving very thin headroom on the
# default 2MB thread stack. 4MB gives ~2x buffer against flaky
# overflows under parallel-test contention.
RUST_MIN_STACK: 4194304
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
@@ -10,6 +10,7 @@ on:
- "backend/windmill-api/openapi.yaml"
- "cli/src/main.ts"
- "cli/src/commands/**"
- "frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts"
pull_request:
paths:
- "system_prompts/**"
@@ -19,6 +20,7 @@ on:
- "backend/windmill-api/openapi.yaml"
- "cli/src/main.ts"
- "cli/src/commands/**"
- "frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts"
jobs:
check-freshness:
+3
View File
@@ -25,7 +25,10 @@ rust-client/Cargo.toml
backend/target
frontend/node_modules
typescript-client/node_modules
ai_evals/node_modules
ai_evals/results/
frontend/.svelte-kit
backend/chrome_profiler.json
.fast-check/
__pycache__/
.playwright-mcp/
+87
View File
@@ -0,0 +1,87 @@
# Windmill
Open-source platform for internal tools, workflows, API integrations, background jobs, and UIs. Rust backend + Svelte 5 frontend.
## Workflow
1. **Understand**: Before coding, explore the codebase (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code, `Grep` to find usages. Read `docs/` for domain context.
2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages
3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`)
4. **Validate**: After every change, run the appropriate checks per `docs/validation.md`
## Documentation
- **Validation**: `docs/validation.md` — what checks to run based on what you changed
- **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.
- **Code review**: use `/local-review` to review a PR for bugs and CLAUDE.md compliance
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
## Dev Environment
- **Backend**: `cargo run` from `backend/` (API at http://localhost:8000)
- **Frontend**: `REMOTE=http://localhost:8000 npm run dev` from `frontend/` (port 3000+)
- **DB**: `psql postgres://postgres:changeme@localhost:5432/windmill`
- **Login**: `admin@windmill.dev` / `changeme`
- **Instance settings**: navigate to `/#superadmin-settings`
- **Migrations**: use `cargo sqlx migrate add -r <name>` from `backend/` to create new migrations (never generate timestamps manually)
## Banned Patterns
### `$bindable(default_value)` on optional props
Using `$bindable(default_value)` on props that can be `undefined` is **banned**. This pattern causes subtle bugs because the default value masks the `undefined` state.
**Bad:**
```svelte
let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props()
```
**Correct alternatives:**
1. **Use `$derived` with nullish coalescing** — handle the potential `undefined` at the usage site:
```svelte
let { my_prop = $bindable() }: { my_prop?: string } = $props()
let effective_value = $derived(my_prop ?? default_value)
```
2. **Create a `useMyPropState()` helper** — encapsulate the undefined-handling logic in a reusable function and call it higher in the component tree, so the child component always receives a defined value.
## Code Navigation
`wm-ts-nav` is an AST-aware code navigator. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries.
**MUST use `outline` before `Read`** on unfamiliar files — a 500-line file costs ~500 lines of context, while `outline` costs ~20. Then **MUST use `body "X"`** instead of reading a full file to see one function/struct. Use `Read` with offset/limit only when you need surrounding context that `body` doesn't capture.
- `refs "X" --caller` instead of reading files to find which function contains each reference
- `callers "X"` / `callees "X"` for call-graph questions
EE files (`*_ee.rs`, `*_ee.ts`, `*_ee.svelte`) are indexed — you can `outline`, `def`, `body`, `refs` etc. on them just like regular files.
```bash
NAV="sh wm-ts-nav/nav"
# Use --root backend for Rust, --root frontend/src for TS/Svelte
$NAV --root backend outline backend/path/to/file.rs # file structure
$NAV --root backend def "ServiceName" # find definition
$NAV --root backend body "decrypt_oauth_data" # extract source code
$NAV --root backend search "%" --parent ServiceName # methods on a type
$NAV --root backend search "Trigger" --kind struct # find by kind
$NAV --root backend refs "X" --file handler.rs --caller # scoped refs with caller
$NAV --root backend callers "X" # who calls X?
$NAV --root backend callees "X" # what does X call?
```
**Limitations** — syntax-level analysis, no type inference. Use **Grep** instead when completeness matters (finding all usages, exhaustiveness checks):
- `refs`/`callers`/`callees` can't follow re-exports, glob imports, or different import paths to the same symbol
- Trait impls, macro-generated symbols (`sqlx::FromRow`), and namespace member access (`ns.X`) are invisible
- `callees` shows all identifiers in a function body, not just actual calls
## Core Principles
- **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics
- 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
+461
View File
@@ -1,5 +1,466 @@
# Changelog
## [1.693.0](https://github.com/windmill-labs/windmill/compare/v1.692.0...v1.693.0) (2026-04-29)
### Features
* add ai chat schedule and trigger tools ([#8961](https://github.com/windmill-labs/windmill/issues/8961)) ([b883f9a](https://github.com/windmill-labs/windmill/commit/b883f9a9d2e38a5981860de268fa6227cdd645de))
* add delete_after_secs and sensitive_inputs for raw app runnables ([#8975](https://github.com/windmill-labs/windmill/issues/8975)) ([1169d9b](https://github.com/windmill-labs/windmill/commit/1169d9bfd315e43194f9e5a2bc55af843476ef68))
* add min release age instance settings for bun and uv ([#8956](https://github.com/windmill-labs/windmill/issues/8956)) ([1d279e7](https://github.com/windmill-labs/windmill/commit/1d279e7a1e77fd183bb0c99d2430cfd9dc0a617c))
* edit scopes on existing API tokens ([#8967](https://github.com/windmill-labs/windmill/issues/8967)) ([e9e72fb](https://github.com/windmill-labs/windmill/commit/e9e72fbbf83363ba1426b3dde3d657de02ba50b3))
* OTEL span status on failed jobs + Python stderr severity classification ([#8918](https://github.com/windmill-labs/windmill/issues/8918)) ([cec8484](https://github.com/windmill-labs/windmill/commit/cec84849b9aea92d355dd346546969027c603498))
* support restart from steps inside BranchOne, ForLoop, Subflow ([#8955](https://github.com/windmill-labs/windmill/issues/8955)) ([c956428](https://github.com/windmill-labs/windmill/commit/c95642863e366c106d529a57540a75df9480397c))
* support S3Object input args in native SQL scripts ([#8954](https://github.com/windmill-labs/windmill/issues/8954)) ([c0eeea9](https://github.com/windmill-labs/windmill/commit/c0eeea9c833f9be3981389a19d0964400fd2bda8))
* workspace-shared ui/ folder reusable across raw apps ([#8974](https://github.com/windmill-labs/windmill/issues/8974)) ([de0b6b1](https://github.com/windmill-labs/windmill/commit/de0b6b15285612ef00b94e332dcb203aed88f2cc))
### Bug Fixes
* **cli:** debounce wmill dev flow round-trip 200ms ([#8977](https://github.com/windmill-labs/windmill/issues/8977)) ([5861dca](https://github.com/windmill-labs/windmill/commit/5861dcad589df99f57e730d8d9cdb3eabc3424e4))
* prevent React app editor from overwriting files on theme switch ([#8965](https://github.com/windmill-labs/windmill/issues/8965)) ([70b90c4](https://github.com/windmill-labs/windmill/commit/70b90c41dc28d1a850bce69836cde920c1404c3b))
* show skipped label on flow progress bar ([#8973](https://github.com/windmill-labs/windmill/issues/8973)) ([8627d3c](https://github.com/windmill-labs/windmill/commit/8627d3c5aeabfac12a9f06211f8e7db020e758f9))
* split flow prompts for frontend chat ([#8968](https://github.com/windmill-labs/windmill/issues/8968)) ([4098793](https://github.com/windmill-labs/windmill/commit/4098793db22249c5b4467c2adb72131407f5d6d3))
* strip additionalProperties from google schemas ([#8964](https://github.com/windmill-labs/windmill/issues/8964)) ([77d9a53](https://github.com/windmill-labs/windmill/commit/77d9a534235a1ff8cbfcdb037a65735db987e2fe))
### Performance Improvements
* optimize datatable app chat schemas ([#8960](https://github.com/windmill-labs/windmill/issues/8960)) ([34b549c](https://github.com/windmill-labs/windmill/commit/34b549cfe2e2e060561eabe369a99cfb4d9c9568))
## [1.692.0](https://github.com/windmill-labs/windmill/compare/v1.691.1...v1.692.0) (2026-04-27)
### Features
* add agents skills to cli init ([#8948](https://github.com/windmill-labs/windmill/issues/8948)) ([abbfd50](https://github.com/windmill-labs/windmill/commit/abbfd504ac6dd8fe3aa2bb3acfab7f72f24b81c6))
* **cli:** wmill dev with per-flow proxy and responsive Dev UI ([#8529](https://github.com/windmill-labs/windmill/issues/8529)) ([eebe24d](https://github.com/windmill-labs/windmill/commit/eebe24d8b0739a61d308bc53a322a01df984511a))
### Bug Fixes
* Audit logs filters UI spacing ([#8944](https://github.com/windmill-labs/windmill/issues/8944)) ([15bba79](https://github.com/windmill-labs/windmill/commit/15bba79ef25988f923b9604a58b719f9f244c641))
* delete instance settings cleared via bulk endpoint ([#8949](https://github.com/windmill-labs/windmill/issues/8949)) ([e8f7589](https://github.com/windmill-labs/windmill/commit/e8f7589d7a9cba5b050a35ff7b92116e679566f6))
* prevent flow-dep job stalls under row-lock contention ([#8952](https://github.com/windmill-labs/windmill/issues/8952)) ([e636f58](https://github.com/windmill-labs/windmill/commit/e636f589a534b29f5deceed3674c6b7d312cb6d4))
* **wac:** recognize [@workflow](https://github.com/workflow) main, list WAC in scripts/list, run preprocessor ([#8951](https://github.com/windmill-labs/windmill/issues/8951)) ([581658d](https://github.com/windmill-labs/windmill/commit/581658d881dd35e39a3fb8f4216d2f91d4184b03))
## [1.691.1](https://github.com/windmill-labs/windmill/compare/v1.691.0...v1.691.1) (2026-04-27)
### Bug Fixes
* **cli:** preserve case in raw-app runnable filenames ([#8940](https://github.com/windmill-labs/windmill/issues/8940)) ([2f58a31](https://github.com/windmill-labs/windmill/commit/2f58a31d009025c18e8eba087ea7001f02639615))
* preserve s3 rootPath when reloading git repo viewer ([#8942](https://github.com/windmill-labs/windmill/issues/8942)) ([b8fcb7f](https://github.com/windmill-labs/windmill/commit/b8fcb7f04b6a47cd913710522ead37cc07c56fdd))
## [1.691.0](https://github.com/windmill-labs/windmill/compare/v1.690.0...v1.691.0) (2026-04-24)
### Features
* add auto-login SSO provider instance setting ([#8929](https://github.com/windmill-labs/windmill/issues/8929)) ([4cf53a4](https://github.com/windmill-labs/windmill/commit/4cf53a44bb10b65dcdb45bac97186a10cdbb48d6))
* cli diff/deploy no-op handling + promotion debouncing ([#8936](https://github.com/windmill-labs/windmill/issues/8936)) ([489337d](https://github.com/windmill-labs/windmill/commit/489337d5333e31164d83efad5f0fb433f4093640))
* **cli:** non-interactive Slack connect/disconnect + sync round-trip fixes ([#8935](https://github.com/windmill-labs/windmill/issues/8935)) ([95d4c6a](https://github.com/windmill-labs/windmill/commit/95d4c6a94dfdaf311cba44b2049202dcd819c835))
* WM_TESTED_RUNNABLE env var + wildcards in test: annotation ([#8926](https://github.com/windmill-labs/windmill/issues/8926)) ([8a98650](https://github.com/windmill-labs/windmill/commit/8a986500b932753508bf5f380f6458a9e1375449))
### Bug Fixes
* **autoscaling:** native worker stuck at max + wrong TimeAgo ([#8930](https://github.com/windmill-labs/windmill/issues/8930)) ([73fab0c](https://github.com/windmill-labs/windmill/commit/73fab0c26441678c5efaf8b38f57ba0bd7293522))
* **nativets:** forward OTEL-prefixed console logs to tracing events ([#8937](https://github.com/windmill-labs/windmill/issues/8937)) ([e732004](https://github.com/windmill-labs/windmill/commit/e732004180728a2dfa45083225d204d9fde89d06))
## [1.690.0](https://github.com/windmill-labs/windmill/compare/v1.689.0...v1.690.0) (2026-04-23)
### Features
* add ai agent conversation output control ([#8915](https://github.com/windmill-labs/windmill/issues/8915)) ([9a60ff2](https://github.com/windmill-labs/windmill/commit/9a60ff2e77f197786f523755c3a9286a178a245c))
* add Azure Event Grid triggers ([#8888](https://github.com/windmill-labs/windmill/issues/8888)) ([d6c642b](https://github.com/windmill-labs/windmill/commit/d6c642b170b9547fe1d8db190affa35b305c9c8a))
* add OTEL_HOST_NAME env override for host.name attribute ([#8923](https://github.com/windmill-labs/windmill/issues/8923)) ([f429cb5](https://github.com/windmill-labs/windmill/commit/f429cb5e486aa5d2bd37f84c1fb30b9d350909e4))
### Bug Fixes
* **cli:** use wmill.yaml key consistently for workspace-specific items ([#8900](https://github.com/windmill-labs/windmill/issues/8900)) ([1722a7a](https://github.com/windmill-labs/windmill/commit/1722a7a2af5e00beeae204b78e588cd74a3ceb39))
* correct flow conversation pagination ([#8919](https://github.com/windmill-labs/windmill/issues/8919)) ([7fa924e](https://github.com/windmill-labs/windmill/commit/7fa924e67e212458726a839dbe366798b2709cd6))
* ensure schema is inferred on script/flow module load ([#8927](https://github.com/windmill-labs/windmill/issues/8927)) ([664d0f8](https://github.com/windmill-labs/windmill/commit/664d0f838d168978d7c27e88d2bb9019531e7ea1))
* include endpoint descriptions in mcp tools ([#8925](https://github.com/windmill-labs/windmill/issues/8925)) ([07951e8](https://github.com/windmill-labs/windmill/commit/07951e81ae9a1c26e8fe63bcd7a760b80500ca4c))
* load job metadata on approval page via approval token ([#8924](https://github.com/windmill-labs/windmill/issues/8924)) ([dac29e7](https://github.com/windmill-labs/windmill/commit/dac29e7d23d6e980c2b6fc4dd3a05a0d2e0170b3))
* slim app ai chat context ([#8922](https://github.com/windmill-labs/windmill/issues/8922)) ([132d8a6](https://github.com/windmill-labs/windmill/commit/132d8a61f9c109b2b447fab1a39565f52864b746))
## [1.689.0](https://github.com/windmill-labs/windmill/compare/v1.688.0...v1.689.0) (2026-04-22)
### Features
* add s3 stream progress logs to other DB executors ([#8898](https://github.com/windmill-labs/windmill/issues/8898)) ([2d4fadb](https://github.com/windmill-labs/windmill/commit/2d4fadb590590837412d638192fbd62bdc9331e8))
* allow hiding catalog picker and raw input on s3 form fields ([#8902](https://github.com/windmill-labs/windmill/issues/8902)) ([05baa4a](https://github.com/windmill-labs/windmill/commit/05baa4ab026a307267d11fb827f8abcc246d1ac0))
* async dep endpoints and queue-position logs in cli ([#8895](https://github.com/windmill-labs/windmill/issues/8895)) ([aaf3a19](https://github.com/windmill-labs/windmill/commit/aaf3a1974746be451adf463fd1f0e584b2fa995e))
* auto-strip UTF-8 BOM when reading local files in CLI ([#8911](https://github.com/windmill-labs/windmill/issues/8911)) ([99bc96d](https://github.com/windmill-labs/windmill/commit/99bc96d0b231a2af303b28aa87d5de9141ee5cab))
### Bug Fixes
* add aws-config to private feature to restore ce build ([18eed92](https://github.com/windmill-labs/windmill/commit/18eed92dd66d635305a72818e2fcf0ee8b9672cc))
* add flow conversation token scope ([#8903](https://github.com/windmill-labs/windmill/issues/8903)) ([aea7444](https://github.com/windmill-labs/windmill/commit/aea74445a31d30abb8030763db91e1829db772f0))
* add proxy eval coverage for gemini schemas ([#8897](https://github.com/windmill-labs/windmill/issues/8897)) ([fddd8e2](https://github.com/windmill-labs/windmill/commit/fddd8e288fc0fd7af3b1df1ddd4476fb57694ed3))
* apply powershell workspace dependencies to deployed scripts ([#8912](https://github.com/windmill-labs/windmill/issues/8912)) ([dc89673](https://github.com/windmill-labs/windmill/commit/dc896737ac1dcd90ab96314b2bc2f044ff833b8a))
* detect and clearly label OOM in zombie flow alerts ([#8901](https://github.com/windmill-labs/windmill/issues/8901)) ([680c711](https://github.com/windmill-labs/windmill/commit/680c711f9262683c046a78532b22be5f5a4121a8))
* omit default_permissioned_as from tarball export when empty ([bbb564c](https://github.com/windmill-labs/windmill/commit/bbb564c1420593014d17f352ba38d3ed38c248e1))
* persist flow groups from AI chat tool calls ([#8906](https://github.com/windmill-labs/windmill/issues/8906)) ([932d183](https://github.com/windmill-labs/windmill/commit/932d18331196ef3e87c45d8a06ae45ac8013bd7a))
* push parent resource on fileset child add/delete ([#8910](https://github.com/windmill-labs/windmill/issues/8910)) ([f29badc](https://github.com/windmill-labs/windmill/commit/f29badcf368e7c712f1515fa30a6a0e179a4bdc5))
* rust nsjail RUSTUP_HOME mount and arch-aware cache keys ([#8890](https://github.com/windmill-labs/windmill/issues/8890)) ([f8c916c](https://github.com/windmill-labs/windmill/commit/f8c916cb6073f5566289c395ec39ec3919f449e7))
* skip opus 4.7 sampling params ([#8904](https://github.com/windmill-labs/windmill/issues/8904)) ([1e83278](https://github.com/windmill-labs/windmill/commit/1e83278fe2ef5a5c6959a9351e7286d9dbf2453a))
* support windmill chat answer override ([#8909](https://github.com/windmill-labs/windmill/issues/8909)) ([eeb5d12](https://github.com/windmill-labs/windmill/commit/eeb5d12be3ba2aedf2ebc4843d4395e241ecc8d3))
* track dollar-quoted strings in SQL block splitter ([#8891](https://github.com/windmill-labs/windmill/issues/8891)) ([53badf1](https://github.com/windmill-labs/windmill/commit/53badf1a8cff576bb4ccbc75f045efb457b6a07d))
* trigger failure_module when branchone predicate throws ([#8905](https://github.com/windmill-labs/windmill/issues/8905)) ([dffb89e](https://github.com/windmill-labs/windmill/commit/dffb89e00632bd4e7bbe9998bccb1f14357d9c07)), closes [#8889](https://github.com/windmill-labs/windmill/issues/8889)
## [1.688.0](https://github.com/windmill-labs/windmill/compare/v1.687.0...v1.688.0) (2026-04-20)
### Features
* add homepage connect drawer ([#8880](https://github.com/windmill-labs/windmill/issues/8880)) ([f35e10c](https://github.com/windmill-labs/windmill/commit/f35e10cc0aed86e5230db5786ad1d6d33bb37b94))
* improve app evals and localized app edits ([#8863](https://github.com/windmill-labs/windmill/issues/8863)) ([46b2915](https://github.com/windmill-labs/windmill/commit/46b2915a9d6350452d1f43ef108e7925b7f879e0))
### Bug Fixes
* batch cancel dropping jobs from other workspaces ([#8887](https://github.com/windmill-labs/windmill/issues/8887)) ([10d1a93](https://github.com/windmill-labs/windmill/commit/10d1a932d50044bedfd3c837c7980d68df49bbe6))
* log boolean predicate eval errors to root flow logs ([#8885](https://github.com/windmill-labs/windmill/issues/8885)) ([94f27af](https://github.com/windmill-labs/windmill/commit/94f27af838294bc76bac57fcd1784a21675aec1c))
* populate wmill.d.ts schemas in wmill app dev ([#8882](https://github.com/windmill-labs/windmill/issues/8882)) ([12c08cc](https://github.com/windmill-labs/windmill/commit/12c08cc95c2aedbcad50fe0e6392b30fd8438e49))
* use POST for oidc token request in authed client ([#8883](https://github.com/windmill-labs/windmill/issues/8883)) ([71c4212](https://github.com/windmill-labs/windmill/commit/71c4212a903870357ff62bf90343a2fa0aeaac79))
### Performance Improvements
* speed up mssql s3 ingest and add phase logs to job output ([#8884](https://github.com/windmill-labs/windmill/issues/8884)) ([43a6b57](https://github.com/windmill-labs/windmill/commit/43a6b575817ebe386299c022b9bbb5f3e92ccffe))
## [1.687.0](https://github.com/windmill-labs/windmill/compare/v1.686.0...v1.687.0) (2026-04-17)
### Features
* add disable_password_login global setting ([#8873](https://github.com/windmill-labs/windmill/issues/8873)) ([0cfa131](https://github.com/windmill-labs/windmill/commit/0cfa131254a517331124a83ae21fbe6508a8c24f))
* add GitHub as a native trigger service ([#8856](https://github.com/windmill-labs/windmill/issues/8856)) ([4f998cc](https://github.com/windmill-labs/windmill/commit/4f998cc231119ba0ba5224223222b1d4d5976e22))
### Bug Fixes
* default null script/flow schema to empty in bg runnable ([#8872](https://github.com/windmill-labs/windmill/issues/8872)) ([1d2d12a](https://github.com/windmill-labs/windmill/commit/1d2d12a27de5731b6bf3e4dae14025ec87b7b4b8))
* mint fresh Google channel IDs on update/renew ([#8870](https://github.com/windmill-labs/windmill/issues/8870)) ([ad2e855](https://github.com/windmill-labs/windmill/commit/ad2e855a83c94299c6fa9f572acb7e4688346e2e))
## [1.686.0](https://github.com/windmill-labs/windmill/compare/v1.685.0...v1.686.0) (2026-04-17)
### Features
* add empty inline script warnings to flow chat ([#8853](https://github.com/windmill-labs/windmill/issues/8853)) ([51b09ac](https://github.com/windmill-labs/windmill/commit/51b09ace45440acd055f95b16c4ad6451ea5c8d5))
* generate tsconfig.json during wmill init for IDE type support ([#8855](https://github.com/windmill-labs/windmill/issues/8855)) ([0b6874f](https://github.com/windmill-labs/windmill/commit/0b6874fb0d356e7f71b3914066af310e746b4b97))
* migrate slack OAuth to v2 ([#8859](https://github.com/windmill-labs/windmill/issues/8859)) ([b1a4c78](https://github.com/windmill-labs/windmill/commit/b1a4c780dcfc06766b79683d37f1ae66e021adf9))
### Bug Fixes
* fix otel tracing on nativets ([172a7d1](https://github.com/windmill-labs/windmill/commit/172a7d16dbac420ba1b0174d8e7eee7ba78406cd))
* include app owner in GitHub App URL for GHE Cloud ([#8846](https://github.com/windmill-labs/windmill/issues/8846)) ([e9ea06f](https://github.com/windmill-labs/windmill/commit/e9ea06f4c2b35dad2177a2fafc727463e2ce9f4e))
* serve populated jwks at /.well-known/jwks.json for vault ([#8865](https://github.com/windmill-labs/windmill/issues/8865)) ([8514347](https://github.com/windmill-labs/windmill/commit/85143477841498fb8e8c947feb89d58d06d425d3))
* update on_behalf_of_email in app policy on offboarding ([#8858](https://github.com/windmill-labs/windmill/issues/8858)) ([d99a176](https://github.com/windmill-labs/windmill/commit/d99a176b6ace181d5c99140a33e237bc8646fdc3))
## [1.685.0](https://github.com/windmill-labs/windmill/compare/v1.684.1...v1.685.0) (2026-04-16)
### Features
* add compact json patch tool to flow chat ([#8840](https://github.com/windmill-labs/windmill/issues/8840)) ([b39671d](https://github.com/windmill-labs/windmill/commit/b39671d933e789301017d3efce6a448f35b3d407))
### Bug Fixes
* classify fileset resource files with script extensions correctly ([#8851](https://github.com/windmill-labs/windmill/issues/8851)) ([b1aeb33](https://github.com/windmill-labs/windmill/commit/b1aeb33adeb63a1ee521e7a1aacc552faab833da))
* clean ai memory and cache bedrock prompts ([#8847](https://github.com/windmill-labs/windmill/issues/8847)) ([b177827](https://github.com/windmill-labs/windmill/commit/b1778272fc91d53922ee62fe4be304ff72767f0b))
* encourage subflow reuse in AI chat flow builder prompt ([#8839](https://github.com/windmill-labs/windmill/issues/8839)) ([49844eb](https://github.com/windmill-labs/windmill/commit/49844eb240a24caacd3a86b5eb6b3c228e8c5fbe))
* improve flow chat and benchmark coverage ([#8825](https://github.com/windmill-labs/windmill/issues/8825)) ([d3cb0c6](https://github.com/windmill-labs/windmill/commit/d3cb0c62204ebfebfa6859f38b3b597d719c573a))
* include workspacedependencies in default git sync include_type ([#8852](https://github.com/windmill-labs/windmill/issues/8852)) ([fc49a8f](https://github.com/windmill-labs/windmill/commit/fc49a8fed655a66b89e2324f49121402cdffd507))
* make sync pull produce consistent wmill-lock.yaml hashes ([#8854](https://github.com/windmill-labs/windmill/issues/8854)) ([625d23f](https://github.com/windmill-labs/windmill/commit/625d23fc85a4b80723d0b267c3fc790dc60993bc))
* parse assets on inline script module creation to avoid false toast ([#8835](https://github.com/windmill-labs/windmill/issues/8835)) ([12d0a3d](https://github.com/windmill-labs/windmill/commit/12d0a3de0829fb7951d1be93ddbbca582781a9cc))
* per-branch concurrency key for promotion-mode git sync ([#8844](https://github.com/windmill-labs/windmill/issues/8844)) ([362ae24](https://github.com/windmill-labs/windmill/commit/362ae248fe899368fee05046976f01bc2f128c3f))
* preserve gemini thought signatures in ai chat ([#8837](https://github.com/windmill-labs/windmill/issues/8837)) ([5c179e5](https://github.com/windmill-labs/windmill/commit/5c179e5448a448d5f9a33484a7205807e5cf107b))
* skip nsjail uidmap/gidmap when DISABLE_NUSER=true ([#8842](https://github.com/windmill-labs/windmill/issues/8842)) ([4bda600](https://github.com/windmill-labs/windmill/commit/4bda600729f907514f3f58728f2e592d4d1495ed))
* Update duckdb to 1.5.2 (Ducklake 1.0.0) ([#8848](https://github.com/windmill-labs/windmill/issues/8848)) ([7f2486b](https://github.com/windmill-labs/windmill/commit/7f2486bdba18b95d9edc68ad63eb48e5567f0d45))
* workspace specfic tags compatibility with forked workspaces ([#8850](https://github.com/windmill-labs/windmill/issues/8850)) ([0773b5b](https://github.com/windmill-labs/windmill/commit/0773b5bc5d809d00350618ed2955a1baf77a26da))
## [1.684.1](https://github.com/windmill-labs/windmill/compare/v1.684.0...v1.684.1) (2026-04-14)
### Bug Fixes
* stop escalating missing email recipients to critical alert ([#8833](https://github.com/windmill-labs/windmill/issues/8833)) ([6158ff2](https://github.com/windmill-labs/windmill/commit/6158ff2ebe29d6a9a7ff4d524e152bb2f7c24dfc))
## [1.684.0](https://github.com/windmill-labs/windmill/compare/v1.683.2...v1.684.0) (2026-04-14)
### Features
* cascade trigger script_path on runnable rename + fix trigger permissioned_as ([#8823](https://github.com/windmill-labs/windmill/issues/8823)) ([64ba3a6](https://github.com/windmill-labs/windmill/commit/64ba3a632eee041d09093e89961f63f2a090fcad))
* **frontend:** improve permissions drawer UX and auto-share resource variables ([#8824](https://github.com/windmill-labs/windmill/issues/8824)) ([91064ce](https://github.com/windmill-labs/windmill/commit/91064ce85712b85e32e3f8cff2a0794cd5597ed6))
### Bug Fixes
* allow dedicated flow substeps to inherit parent tag ([#8832](https://github.com/windmill-labs/windmill/issues/8832)) ([aebf758](https://github.com/windmill-labs/windmill/commit/aebf758412383dd65e0bf6c72de8f2668561cd88))
* compute wall-clock duration for flow job groups in CLI ([#8826](https://github.com/windmill-labs/windmill/issues/8826)) ([e1dbce0](https://github.com/windmill-labs/windmill/commit/e1dbce02c22bcaa3d7d447ee54db69373bc1cf7b))
* DB Manager delete/update for timestamp and serial types ([#8830](https://github.com/windmill-labs/windmill/issues/8830)) ([06fe809](https://github.com/windmill-labs/windmill/commit/06fe809ecc3c6b37af7582175f9dd90c2c2a8f98))
* hide serial types in column type dropdown for existing columns ([#8828](https://github.com/windmill-labs/windmill/issues/8828)) ([7fe639d](https://github.com/windmill-labs/windmill/commit/7fe639d91e93a6b3069e0d87b57c232d67c8ad65))
## [1.683.2](https://github.com/windmill-labs/windmill/compare/v1.683.1...v1.683.2) (2026-04-14)
### Bug Fixes
* detect WAC v2 Python workflows that only use step() (no [@task](https://github.com/task)) ([#8819](https://github.com/windmill-labs/windmill/issues/8819)) ([89c8e4b](https://github.com/windmill-labs/windmill/commit/89c8e4bb9680c179bf44a66a22dcf047334944ae))
* persist indexer max_index_time_window_secs setting ([#8821](https://github.com/windmill-labs/windmill/issues/8821)) ([4dc54ca](https://github.com/windmill-labs/windmill/commit/4dc54ca3aa14beab175da59eb8b9072918301b43))
## [1.683.1](https://github.com/windmill-labs/windmill/compare/v1.683.0...v1.683.1) (2026-04-13)
### Bug Fixes
* use OpenAPI 3.0 nullable pattern for getOpenDeploymentRequest ([#8816](https://github.com/windmill-labs/windmill/issues/8816)) ([f7f26b3](https://github.com/windmill-labs/windmill/commit/f7f26b32244536b6efb7c1b5aafd4a7644dcb42f))
## [1.683.0](https://github.com/windmill-labs/windmill/compare/v1.682.0...v1.683.0) (2026-04-13)
### Features
* add black-box ai eval benchmarks ([#8618](https://github.com/windmill-labs/windmill/issues/8618)) ([cdcc564](https://github.com/windmill-labs/windmill/commit/cdcc56461b77554964622f490ae901f170886595))
* add deploy restriction rule and fork review requests ([#8804](https://github.com/windmill-labs/windmill/issues/8804)) ([64c58c8](https://github.com/windmill-labs/windmill/commit/64c58c824fcefe00f15405b7e3877eb566a3ffa2))
* allow non-admins to create and edit HTTP triggers ([#8810](https://github.com/windmill-labs/windmill/issues/8810)) ([9fb7816](https://github.com/windmill-labs/windmill/commit/9fb78164b4baa14c10d10f91ae969d48590c29f3))
* display agent message in flow graph ([#8806](https://github.com/windmill-labs/windmill/issues/8806)) ([95411b2](https://github.com/windmill-labs/windmill/commit/95411b256332fa41816a93b19906f1534da9b300))
* folder default_permissioned_as rules for ownership defaults on deploy ([#8801](https://github.com/windmill-labs/windmill/issues/8801)) ([60211c1](https://github.com/windmill-labs/windmill/commit/60211c1d1910b5f7ac6fed112f790201d2047a4c))
* instance-level ruff config auto-pulled by LSP container ([#8803](https://github.com/windmill-labs/windmill/issues/8803)) ([3f5841f](https://github.com/windmill-labs/windmill/commit/3f5841f84d878cd3f43c435fa237d3f0c2265fb9))
### Bug Fixes
* **cli:** make cli help resilient to npm registry fetch failures ([#8809](https://github.com/windmill-labs/windmill/issues/8809)) ([b6f1cc7](https://github.com/windmill-labs/windmill/commit/b6f1cc70cd87c61df7112d3838fbb5fe9bcdc145))
* enrich OTEL log records with per-request LogContext ([#8812](https://github.com/windmill-labs/windmill/issues/8812)) ([42d3e8c](https://github.com/windmill-labs/windmill/commit/42d3e8c7893cd959c7faffd19cd210c869c604f8))
* silence user-facing toast for non-critical hub script tracking error ([#8808](https://github.com/windmill-labs/windmill/issues/8808)) ([378ba78](https://github.com/windmill-labs/windmill/commit/378ba7828456c871b5778f1144c4bb559bd5a733))
### Performance Improvements
* add inline-persist fast path for WAC v2 step() ([#8807](https://github.com/windmill-labs/windmill/issues/8807)) ([b3ef4bc](https://github.com/windmill-labs/windmill/commit/b3ef4bc26c5696624efee89b5e4e33e77e10cf15))
## [1.682.0](https://github.com/windmill-labs/windmill/compare/v1.681.0...v1.682.0) (2026-04-10)
### Features
* enrich hanging flow error with worker and service log info ([#8800](https://github.com/windmill-labs/windmill/issues/8800)) ([59c457a](https://github.com/windmill-labs/windmill/commit/59c457a13881e35c229baed3edd87e618f89b9a0))
### Bug Fixes
* bypass OTEL MITM tracing proxy for git sync jobs ([#8796](https://github.com/windmill-labs/windmill/issues/8796)) ([9c85565](https://github.com/windmill-labs/windmill/commit/9c855652212dbac0e49f87dedd447d3d7d7b500a))
* show full path on hover in deploy drawer and widen drawer ([#8799](https://github.com/windmill-labs/windmill/issues/8799)) ([b783bf2](https://github.com/windmill-labs/windmill/commit/b783bf2d835cde0843739f7d1099193bb0af042e))
## [1.681.0](https://github.com/windmill-labs/windmill/compare/v1.680.0...v1.681.0) (2026-04-10)
### Features
* add CI test scripts with auto-trigger on deploy ([#8736](https://github.com/windmill-labs/windmill/issues/8736)) ([c57c769](https://github.com/windmill-labs/windmill/commit/c57c769deaa207e7ba7995f75649d3630774e898))
* add edit yaml button to raw app settings ([#8771](https://github.com/windmill-labs/windmill/issues/8771)) ([b73be37](https://github.com/windmill-labs/windmill/commit/b73be37916de808dc64bec1337edf6e7d3993c5e))
* add user offboarding flow with object reassignment ([#8647](https://github.com/windmill-labs/windmill/issues/8647)) ([435b25e](https://github.com/windmill-labs/windmill/commit/435b25e6a4c7272c0189cbcfb83526379f41ebf0))
* allow selecting hub flows as raw app backend runnables ([#8772](https://github.com/windmill-labs/windmill/issues/8772)) ([5f57727](https://github.com/windmill-labs/windmill/commit/5f57727a4d956a9066b005b3c55f08dd6780475a))
* list external JWT tokens in instance settings ([#8783](https://github.com/windmill-labs/windmill/issues/8783)) ([ce3e676](https://github.com/windmill-labs/windmill/commit/ce3e676f4ab0c442058c64db4ebf35545a805ef5))
* oauth manual connect option ([#8770](https://github.com/windmill-labs/windmill/issues/8770)) ([4b87639](https://github.com/windmill-labs/windmill/commit/4b876392a0ce41ae42bd882ced10fe0187e532bc))
* unify CLI config to workspaces, deprecate gitBranches/environments ([#8767](https://github.com/windmill-labs/windmill/issues/8767)) ([5b97092](https://github.com/windmill-labs/windmill/commit/5b9709299761b83a88df17a4259c431dfcd244f9))
* **vault:** add skip_ssl_verify option for HashiCorp Vault ([#8791](https://github.com/windmill-labs/windmill/issues/8791)) ([6cf7ffc](https://github.com/windmill-labs/windmill/commit/6cf7ffc26bcbc8f4ef0e4ad2879fcd114332c4e2))
### Bug Fixes
* bypass sql type injection during formatting to prevent offset corruption ([#8786](https://github.com/windmill-labs/windmill/issues/8786)) ([8957d8f](https://github.com/windmill-labs/windmill/commit/8957d8f19bce3430871c2858b3accd53e0be178f))
* CLI falls back to workspace whoami for workspace-scoped tokens ([#8789](https://github.com/windmill-labs/windmill/issues/8789)) ([d243eb3](https://github.com/windmill-labs/windmill/commit/d243eb31b014781a249f903b2a467aa58909ddd6))
* disable scroll-to-change-number on number inputs ([#8777](https://github.com/windmill-labs/windmill/issues/8777)) ([e63924e](https://github.com/windmill-labs/windmill/commit/e63924e3778b40486813192dc2913e565e0a765e))
* error on flow/app folder suffix format mismatch during sync push/pull ([#8775](https://github.com/windmill-labs/windmill/issues/8775)) ([1deb31f](https://github.com/windmill-labs/windmill/commit/1deb31f1e01d6168eee3c2cc242cb483272d1965))
* flow dev page layout and compact toolbar improvements ([#8776](https://github.com/windmill-labs/windmill/issues/8776)) ([89920e7](https://github.com/windmill-labs/windmill/commit/89920e77f3f5dc45db939ec938d92c881dccc8a0))
* Flow status viewer layout nits (avoid excess y space and scroll) ([#8780](https://github.com/windmill-labs/windmill/issues/8780)) ([6d36eca](https://github.com/windmill-labs/windmill/commit/6d36eca21684f9d3ab36658c2b66f85b9be8d331))
* flow step testing UX improvements ([#8781](https://github.com/windmill-labs/windmill/issues/8781)) ([3fb557a](https://github.com/windmill-labs/windmill/commit/3fb557a7f51dbbd3fac445734196f1b9a1d2e287))
* hide legacy global_settings.worker_configs ghost row ([#8790](https://github.com/windmill-labs/windmill/issues/8790)) ([4fff89f](https://github.com/windmill-labs/windmill/commit/4fff89f98ce72997a055cc313c8fe217d2f1fe78))
* limit multi-runnable dedicated workers to one job at a time ([#8782](https://github.com/windmill-labs/windmill/issues/8782)) ([946848f](https://github.com/windmill-labs/windmill/commit/946848feef60aba2a54bc2f5b686b33cc96ec9ef))
* normalize multi-word pg types in build_parameters to fix float8 serialization ([#8778](https://github.com/windmill-labs/windmill/issues/8778)) ([3d02be9](https://github.com/windmill-labs/windmill/commit/3d02be98f748d985f688243f3215d15ca4227f8f))
* refresh custom instance user password if auth failed ([#8787](https://github.com/windmill-labs/windmill/issues/8787)) ([3d43d31](https://github.com/windmill-labs/windmill/commit/3d43d31aba276f23903f16f06035a4c4955b52e2))
* treat empty global setting strings as unset ([#8793](https://github.com/windmill-labs/windmill/issues/8793)) ([ec9cec1](https://github.com/windmill-labs/windmill/commit/ec9cec1d02d87328db92a71a1b3a945e9e0c6bd2))
* zero-downtime coordinated restarts for OTEL and other setting changes ([#8768](https://github.com/windmill-labs/windmill/issues/8768)) ([506b7f5](https://github.com/windmill-labs/windmill/commit/506b7f55e17472d1384e9676c1b6df7a9d7a118b))
## [1.680.0](https://github.com/windmill-labs/windmill/compare/v1.679.0...v1.680.0) (2026-04-08)
### Features
* add CLI workspace merge command and enhance fork with datatable/color support ([#8756](https://github.com/windmill-labs/windmill/issues/8756)) ([4342c18](https://github.com/windmill-labs/windmill/commit/4342c1854134500d3b2bc46280f9885ee84e2c9e))
* add scheduled job deletion with configurable retention period ([#8753](https://github.com/windmill-labs/windmill/issues/8753)) ([2d18a68](https://github.com/windmill-labs/windmill/commit/2d18a680991babe317ca315bbce40e6ce733afda))
* add status indicator dots to parallel loop iteration picker ([#8761](https://github.com/windmill-labs/windmill/issues/8761)) ([470b8aa](https://github.com/windmill-labs/windmill/commit/470b8aa5f1870e26fea022c1e2a9f48471d8a205))
### Bug Fixes
* move alert config from config table to global_settings ([#8762](https://github.com/windmill-labs/windmill/issues/8762)) ([fa66870](https://github.com/windmill-labs/windmill/commit/fa668707c0ee7f261d78e145666b1073471259fd))
* resolve esbuild host/binary version mismatch in app sync push ([#8765](https://github.com/windmill-labs/windmill/issues/8765)) ([e36d440](https://github.com/windmill-labs/windmill/commit/e36d440a251a43ea888e3ce378d0bb8ed8f42e11))
* skip serializing ws_specific on resources when false ([#8764](https://github.com/windmill-labs/windmill/issues/8764)) ([c69f10d](https://github.com/windmill-labs/windmill/commit/c69f10d20dd064f0c329934096c2945424ff81f2))
## [1.679.0](https://github.com/windmill-labs/windmill/compare/v1.678.0...v1.679.0) (2026-04-07)
### Features
* Fork datatables ([#8339](https://github.com/windmill-labs/windmill/issues/8339)) ([3d4f4c6](https://github.com/windmill-labs/windmill/commit/3d4f4c6c38155396e9b2236a6a7a7ad4e02da877))
## [1.678.0](https://github.com/windmill-labs/windmill/compare/v1.677.0...v1.678.0) (2026-04-07)
### Features
* accept any content type on webhooks/http triggers with fallback ([#8743](https://github.com/windmill-labs/windmill/issues/8743)) ([208a597](https://github.com/windmill-labs/windmill/commit/208a597d599b4d203f7ab817a5d8ce2c06f79d0a))
* add download all logs button for flow jobs ([#8748](https://github.com/windmill-labs/windmill/issues/8748)) ([d938625](https://github.com/windmill-labs/windmill/commit/d938625785ba301fbd2c5f3d001c320eab1c504c))
### Bug Fixes
* delete raw_script_temp rows before workspace deletion to avoid FK violation ([#8752](https://github.com/windmill-labs/windmill/issues/8752)) ([8b9523e](https://github.com/windmill-labs/windmill/commit/8b9523e03c82c5a095b7cb2d5f70a87b7bbc8608))
* Fix FlowTimeline duplicate key ([#8754](https://github.com/windmill-labs/windmill/issues/8754)) ([2413dbe](https://github.com/windmill-labs/windmill/commit/2413dbefe3cc3b65c28bea437cd4471cf7e9ecba))
* remove span.enter() in dedicated worker to prevent tracing panic ([#8749](https://github.com/windmill-labs/windmill/issues/8749)) ([db55e8e](https://github.com/windmill-labs/windmill/commit/db55e8efb0c9ae198ca5ac7013439a94dfe9f550))
* restore ai agent tool deletion ([#8744](https://github.com/windmill-labs/windmill/issues/8744)) ([2f7ba9e](https://github.com/windmill-labs/windmill/commit/2f7ba9edac1a57dfc0eb3417574c72292855fc56))
## [1.677.0](https://github.com/windmill-labs/windmill/compare/v1.676.0...v1.677.0) (2026-04-06)
### Features
* add AWS Secrets Manager as secret storage backend (Beta) ([#8734](https://github.com/windmill-labs/windmill/issues/8734)) ([09bbc18](https://github.com/windmill-labs/windmill/commit/09bbc18bb773d9ffaa5aaa4bd9d7ce296f3ac468))
### Bug Fixes
* remove stale KMS openapi/description, restore stripped doc comments ([c09a431](https://github.com/windmill-labs/windmill/commit/c09a4311fd73c58acc8f3997428f002598dacce6))
* use runnable key for file naming in generate-metadata to prevent duplicate scripts in raw apps ([#8740](https://github.com/windmill-labs/windmill/issues/8740)) ([edfe074](https://github.com/windmill-labs/windmill/commit/edfe074e98cb3955be0768de7ed19e6ed8525916))
## [1.676.0](https://github.com/windmill-labs/windmill/compare/v1.675.1...v1.676.0) (2026-04-06)
### Features
* add path name autocomplete with ghost text and folder cycling ([#8731](https://github.com/windmill-labs/windmill/issues/8731)) ([e326621](https://github.com/windmill-labs/windmill/commit/e32662169a9762605de2dbe058514ddefbe07982))
### Bug Fixes
* fix custom urls not found ([d2abc0d](https://github.com/windmill-labs/windmill/commit/d2abc0d4300bb53f4035102f214d3c05bf0976a1))
### Performance Improvements
* add partial index for expired cache resource cleanup ([#8728](https://github.com/windmill-labs/windmill/issues/8728)) ([c721fac](https://github.com/windmill-labs/windmill/commit/c721fac466524747de04e3623c8cd62de8bd4dae))
## [1.675.1](https://github.com/windmill-labs/windmill/compare/v1.675.0...v1.675.1) (2026-04-05)
### Bug Fixes
* log cleanup scans S3 orphans and works cross-server ([#8729](https://github.com/windmill-labs/windmill/issues/8729)) ([f703fba](https://github.com/windmill-labs/windmill/commit/f703fba1ef56c89a97b2b4da7b4c188158f4c982))
### Performance Improvements
* add indexes for cleanup deletes on concurrency_key and autoscaling_event ([#8726](https://github.com/windmill-labs/windmill/issues/8726)) ([eae46a2](https://github.com/windmill-labs/windmill/commit/eae46a21a93fe7ab191228658dd5825f472bd851))
## [1.675.0](https://github.com/windmill-labs/windmill/compare/v1.674.2...v1.675.0) (2026-04-05)
### Features
* add object storage usage view and manual log cleanup ([#8724](https://github.com/windmill-labs/windmill/issues/8724)) ([02d0ee9](https://github.com/windmill-labs/windmill/commit/02d0ee919880823a33b112bcaf626a8933e1f715))
### Bug Fixes
* add admin check to count_completed_jobs_detail and document query builder SQL safety ([#8722](https://github.com/windmill-labs/windmill/issues/8722)) ([dd39c11](https://github.com/windmill-labs/windmill/commit/dd39c110a8468bf31d42428fc978cd302426fa86))
* allow private AI base URLs in ai_proxy integration test ([#8715](https://github.com/windmill-labs/windmill/issues/8715)) ([2b865c0](https://github.com/windmill-labs/windmill/commit/2b865c0694d79ce6477e5f14a077b73837007500))
* enrich OTEL spans with job_kind, trigger_kind, trigger, created_by, and script_hash ([#8718](https://github.com/windmill-labs/windmill/issues/8718)) ([7bf6ac2](https://github.com/windmill-labs/windmill/commit/7bf6ac2b694fc829327248ff2480c20c97e03e48))
* split DB health endpoint and add slow query controls ([#8725](https://github.com/windmill-labs/windmill/issues/8725)) ([01e39d9](https://github.com/windmill-labs/windmill/commit/01e39d9cd1b841d085bcc28a578654a5486cf76e))
## [1.674.2](https://github.com/windmill-labs/windmill/compare/v1.674.1...v1.674.2) (2026-04-04)
### Bug Fixes
* enforce RLS on $var: resolution in AI proxy (GHSA-jwg4-v3cj-rvfm) ([#8713](https://github.com/windmill-labs/windmill/issues/8713)) ([ff8e39c](https://github.com/windmill-labs/windmill/commit/ff8e39c69b1438defcaabd9d4906e7adafa7010c))
* SSRF via X-Resource-Path header in AI proxy endpoint ([#8712](https://github.com/windmill-labs/windmill/issues/8712)) ([f394e67](https://github.com/windmill-labs/windmill/commit/f394e674f22af13bb77915f33aa1e8de402b6fe1))
## [1.674.1](https://github.com/windmill-labs/windmill/compare/v1.674.0...v1.674.1) (2026-04-04)
### Bug Fixes
* create pg connection for cloud-hosted jobs instead of panicking ([#8710](https://github.com/windmill-labs/windmill/issues/8710)) ([aff95c3](https://github.com/windmill-labs/windmill/commit/aff95c33b2fd4c248dfaf595b8d18a6dbc50f0e6))
## [1.674.0](https://github.com/windmill-labs/windmill/compare/v1.673.0...v1.674.0) (2026-04-03)
### Features
* add application-level heartbeat support for websocket triggers ([#8686](https://github.com/windmill-labs/windmill/issues/8686)) ([5b7fa63](https://github.com/windmill-labs/windmill/commit/5b7fa63bf1800313e9b82465b8a4399a48634371))
* add Azure Key Vault as secret storage backend ([#8704](https://github.com/windmill-labs/windmill/issues/8704)) ([dcd615f](https://github.com/windmill-labs/windmill/commit/dcd615fdc3c66ec2a8e39c01f8142a7e7c82f534))
* add http/protobuf support for OTEL exporters ([#8702](https://github.com/windmill-labs/windmill/issues/8702)) ([0aea49f](https://github.com/windmill-labs/windmill/commit/0aea49f9607d5cbb5bcfa3068a179c9b7bf9afd6))
* add optional labels to scripts, flows, apps, schedules, triggers ([#8609](https://github.com/windmill-labs/windmill/issues/8609)) ([c4c9ef5](https://github.com/windmill-labs/windmill/commit/c4c9ef5fd7b41052b08ee941725434e8ca4ac970))
* add powershell common parameters support ([#8683](https://github.com/windmill-labs/windmill/issues/8683)) ([0317d58](https://github.com/windmill-labs/windmill/commit/0317d5891cfcfbde7b04795c034c088e933ee3d0))
* sql.raw in Typescript client ([#8706](https://github.com/windmill-labs/windmill/issues/8706)) ([ce290f6](https://github.com/windmill-labs/windmill/commit/ce290f68db866c07b30c97c2c0b3e39fee0a26d8))
* Support .ducklake() and .datatable() in agent workers ([#8697](https://github.com/windmill-labs/windmill/issues/8697)) ([fda68a7](https://github.com/windmill-labs/windmill/commit/fda68a72e5dfcded2350d1ff33ca4c695ab337b7))
### Bug Fixes
* add secretKeyRef support for jwt_secret and rsa_keys ([#8698](https://github.com/windmill-labs/windmill/issues/8698)) ([ba21470](https://github.com/windmill-labs/windmill/commit/ba214709b94f9467738e66b016331e97ac7d5d10))
* align script push metadata warning with generated locks ([#8690](https://github.com/windmill-labs/windmill/issues/8690)) ([6656b46](https://github.com/windmill-labs/windmill/commit/6656b46f10408e1c15961a72cde4c13b5c5b3923))
* debounce S3 proxy logs ([#8694](https://github.com/windmill-labs/windmill/issues/8694)) ([a3073ad](https://github.com/windmill-labs/windmill/commit/a3073ad8244efd9043e27f6731f7b53dbda662c1))
* dedicated worker dispatch, cross-workspace deps, UI improvements ([#8689](https://github.com/windmill-labs/windmill/issues/8689)) ([bffa61e](https://github.com/windmill-labs/windmill/commit/bffa61e33f2305bbeb79a2c91989a47baa7dff31))
* gate relock_skip tests on private feature and update ee-repo-ref ([#8703](https://github.com/windmill-labs/windmill/issues/8703)) ([adc9fe7](https://github.com/windmill-labs/windmill/commit/adc9fe722d8511a5914d81faac40af757e7f5e3f))
* hide deprecated cli metadata commands ([#8699](https://github.com/windmill-labs/windmill/issues/8699)) ([b960598](https://github.com/windmill-labs/windmill/commit/b96059843168c072f24072f93fecd80431e5d4cf))
* optimize S3 proxy performance ([#8685](https://github.com/windmill-labs/windmill/issues/8685)) ([0cfa462](https://github.com/windmill-labs/windmill/commit/0cfa462c379e887fdb5ad5e3bbff7798648d4e91))
* pipeline DISCARD ALL with first query on cached pg connections ([#8707](https://github.com/windmill-labs/windmill/issues/8707)) ([6d58d1a](https://github.com/windmill-labs/windmill/commit/6d58d1a74d1e69b163210a795502a7b3931001b5))
* resolve schedule update deadlock ([#8701](https://github.com/windmill-labs/windmill/issues/8701)) ([27ca417](https://github.com/windmill-labs/windmill/commit/27ca417201c99cf6fe0ae5b52a63c0395033e196))
* support raw app deployment history ([#8657](https://github.com/windmill-labs/windmill/issues/8657)) ([f234df9](https://github.com/windmill-labs/windmill/commit/f234df97ec3cdc480ee9d403370a3512496b024b))
* use pre-aggregated stats for telemetry job usage queries ([#8688](https://github.com/windmill-labs/windmill/issues/8688)) ([cdf3c29](https://github.com/windmill-labs/windmill/commit/cdf3c29664e4142c0f4487c07e585d1af3f97f91))
## [1.673.0](https://github.com/windmill-labs/windmill/compare/v1.672.0...v1.673.0) (2026-04-02)
### Features
* add endpoint to restart workers in a worker group ([#8659](https://github.com/windmill-labs/windmill/issues/8659)) ([f0437eb](https://github.com/windmill-labs/windmill/commit/f0437eba1925a9aa4c430008027d637a0c89ee39))
* add Entra ID (Azure Workload Identity) database auth ([#8526](https://github.com/windmill-labs/windmill/issues/8526)) ([6a5cfbc](https://github.com/windmill-labs/windmill/commit/6a5cfbc159a0ad7925fd7ce5eefc8eaa21bbb70b))
* add LIMIT_WINDOWS_TO_1CU env var for Windows worker memory limits ([#8681](https://github.com/windmill-labs/windmill/issues/8681)) ([d2d6810](https://github.com/windmill-labs/windmill/commit/d2d6810db954114f3333853bd3476cb8fc735f92))
* restore bun for dedicated workers, fix dispatch & serialization, cross-workspace deps ([#8645](https://github.com/windmill-labs/windmill/issues/8645)) ([619ebb6](https://github.com/windmill-labs/windmill/commit/619ebb65ce8dce8264add31c3147919802a8286a))
### Bug Fixes
* add HMAC signature verification to Slack interactive callback endpoint ([#8611](https://github.com/windmill-labs/windmill/issues/8611)) ([55e8a5c](https://github.com/windmill-labs/windmill/commit/55e8a5cff1f185b1dbd332d37b877972efa1ed7d))
* correct raw app flow inputs ([#8667](https://github.com/windmill-labs/windmill/issues/8667)) ([28c0730](https://github.com/windmill-labs/windmill/commit/28c073056c65d4ed1600e39679497e5af964347f))
* pass selected language to AI agent when generating flow scripts ([#8680](https://github.com/windmill-labs/windmill/issues/8680)) ([381011a](https://github.com/windmill-labs/windmill/commit/381011a4a8e48454e9c146c64db502293e646b99))
* poll for preview results to avoid undici headers timeout ([#8682](https://github.com/windmill-labs/windmill/issues/8682)) ([ff5fa9f](https://github.com/windmill-labs/windmill/commit/ff5fa9f64fe4aaf33e06b20f02373894b5df0f95))
* pre-fix trigger edited_by for superadmins not in workspace ([#8669](https://github.com/windmill-labs/windmill/issues/8669)) ([350ffdc](https://github.com/windmill-labs/windmill/commit/350ffdce297ba5b84f9dd247eede6da0c6b0956c))
* resolve race condition where flow sync push reverts to stale version ([#8673](https://github.com/windmill-labs/windmill/issues/8673)) ([d569e9e](https://github.com/windmill-labs/windmill/commit/d569e9e29c588243a90b1cd25f866efb0d178640))
* respect disabled fields in JSON input mode ([#8663](https://github.com/windmill-labs/windmill/issues/8663)) ([7fd0bf9](https://github.com/windmill-labs/windmill/commit/7fd0bf974d2ba2644bb01dd5e9ddc84749e166f5))
* Run typed pg queries in a single protocol conversation ([#8679](https://github.com/windmill-labs/windmill/issues/8679)) ([8581a33](https://github.com/windmill-labs/windmill/commit/8581a3300d056040b7e3ab77d629c74f034c9c97))
* sanitize MCP tool schemas for JSON Schema draft 2020-12 compliance ([#8666](https://github.com/windmill-labs/windmill/issues/8666)) ([8c3c97f](https://github.com/windmill-labs/windmill/commit/8c3c97f7a670d47019cc666219f8187f48499672))
* skip generate-metadata confirmation prompt in non-interactive CI ([#8678](https://github.com/windmill-labs/windmill/issues/8678)) ([39af1b7](https://github.com/windmill-labs/windmill/commit/39af1b75afc8458f85dec4fe51dfaed3d0cb000d))
* strip f/ prefix from folder paths when deploying from workspace forks ([#8662](https://github.com/windmill-labs/windmill/issues/8662)) ([7ab0ea5](https://github.com/windmill-labs/windmill/commit/7ab0ea581d349fbfdb56d22cf9903a90efa045bb))
* support branch-specific folder.meta.yaml in missing-meta check ([#8661](https://github.com/windmill-labs/windmill/issues/8661)) ([c87a6a0](https://github.com/windmill-labs/windmill/commit/c87a6a0f2c1346bf5e21f128d32d89bdca039243))
* validate rd redirect on login with same rules as logout ([#8655](https://github.com/windmill-labs/windmill/issues/8655)) ([bcce627](https://github.com/windmill-labs/windmill/commit/bcce62738791a4e9b9f4dbc64731eef163230172))
## [1.672.0](https://github.com/windmill-labs/windmill/compare/v1.671.0...v1.672.0) (2026-04-01)
+1 -86
View File
@@ -1,86 +1 @@
# Windmill
Open-source platform for internal tools, workflows, API integrations, background jobs, and UIs. Rust backend + Svelte 5 frontend.
## Workflow
1. **Understand**: Before coding, explore the codebase (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code, `Grep` to find usages. Read `docs/` for domain context.
2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages
3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`)
4. **Validate**: After every change, run the appropriate checks per `docs/validation.md`
## Documentation
- **Validation**: `docs/validation.md` — what checks to run based on what you changed
- **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.
- **Code review**: use `/local-review` to review a PR for bugs and CLAUDE.md compliance
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
## Dev Environment
- **Backend**: `cargo run` from `backend/` (API at http://localhost:8000)
- **Frontend**: `REMOTE=http://localhost:8000 npm run dev` from `frontend/` (port 3000+)
- **DB**: `psql postgres://postgres:changeme@localhost:5432/windmill`
- **Login**: `admin@windmill.dev` / `changeme`
- **Instance settings**: navigate to `/#superadmin-settings`
## Banned Patterns
### `$bindable(default_value)` on optional props
Using `$bindable(default_value)` on props that can be `undefined` is **banned**. This pattern causes subtle bugs because the default value masks the `undefined` state.
**Bad:**
```svelte
let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props()
```
**Correct alternatives:**
1. **Use `$derived` with nullish coalescing** — handle the potential `undefined` at the usage site:
```svelte
let { my_prop = $bindable() }: { my_prop?: string } = $props()
let effective_value = $derived(my_prop ?? default_value)
```
2. **Create a `useMyPropState()` helper** — encapsulate the undefined-handling logic in a reusable function and call it higher in the component tree, so the child component always receives a defined value.
## Code Navigation
`wm-ts-nav` is an AST-aware code navigator. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries.
**MUST use `outline` before `Read`** on unfamiliar files — a 500-line file costs ~500 lines of context, while `outline` costs ~20. Then **MUST use `body "X"`** instead of reading a full file to see one function/struct. Use `Read` with offset/limit only when you need surrounding context that `body` doesn't capture.
- `refs "X" --caller` instead of reading files to find which function contains each reference
- `callers "X"` / `callees "X"` for call-graph questions
EE files (`*_ee.rs`, `*_ee.ts`, `*_ee.svelte`) are indexed — you can `outline`, `def`, `body`, `refs` etc. on them just like regular files.
```bash
NAV="sh wm-ts-nav/nav"
# Use --root backend for Rust, --root frontend/src for TS/Svelte
$NAV --root backend outline backend/path/to/file.rs # file structure
$NAV --root backend def "ServiceName" # find definition
$NAV --root backend body "decrypt_oauth_data" # extract source code
$NAV --root backend search "%" --parent ServiceName # methods on a type
$NAV --root backend search "Trigger" --kind struct # find by kind
$NAV --root backend refs "X" --file handler.rs --caller # scoped refs with caller
$NAV --root backend callers "X" # who calls X?
$NAV --root backend callees "X" # what does X call?
```
**Limitations** — syntax-level analysis, no type inference. Use **Grep** instead when completeness matters (finding all usages, exhaustiveness checks):
- `refs`/`callers`/`callees` can't follow re-exports, glob imports, or different import paths to the same symbol
- Trait impls, macro-generated symbols (`sqlx::FromRow`), and namespace member access (`ns.X`) are invisible
- `callees` shows all identifiers in a function body, not just actual calls
## Core Principles
- **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics
- 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
@AGENTS.md
+9 -1
View File
@@ -162,11 +162,19 @@ ENV PATH /usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
RUN apt-get update \
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 software-properties-common tini \
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 software-properties-common tini gnupg lsb-release \
&& if echo "$features" | grep -q "ee"; then apt-get install -y --no-install-recommends libsasl2-modules-gssapi-mit krb5-user; fi \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN if [ "$WITH_GIT" = "true" ]; then \
apt-get update -y \
&& apt-get install -y git \
+2
View File
@@ -0,0 +1,2 @@
.env
results/
+172
View File
@@ -0,0 +1,172 @@
# AI Evals Authoring Guide
This folder contains black-box benchmark cases for:
- `flow`
- `app`
- `script`
- `cli`
The goal is to test the current production prompts and guidance with realistic user requests, not to test one exact implementation shape.
## Core rules
1. Write prompts like a real user request.
2. Prefer behavior, inputs, constraints, and outcomes over internal implementation details.
3. Keep deterministic validation narrow and hard.
4. Put semantic expectations in `judgeChecklist`.
5. Use `expected` fixtures only when exact structure really matters.
## Prompt writing
Prompts should sound like something a user would naturally ask.
Good:
- "Create a flow that routes support requests based on customer tier."
- "Add a reset button that sets the counter back to 0."
- "Create a flow that reuses the existing greeting script instead of duplicating the logic."
Bad:
- "Use `branchone` with 3 branches and a default branch."
- "Create a `rawscript` step with this exact topology."
- "This is a benchmark harness."
Do not write prompts as if the user knows Windmill internals unless the case is explicitly testing a power-user workflow.
## Flow-specific rules
This is the main principle you asked for:
- flow prompts should read like requests from a user who does not know the product internals
- the user should ask for behavior, not for `branchone`, `branchall`, `rawscript`, `preprocessor_module`, `failure_module`, exact graph topology, or other internal constructs
That means:
- creation cases should describe the business behavior and expected result
- modification cases may mention existing step names, because the user can see the current flow
- only mention special Windmill constructs when the case is explicitly about those constructs
Examples:
- acceptable creation prompt:
"Create a purchase approval flow that pauses for approval and asks the approver for a comment."
- avoid:
"Create a suspend step with one required event and a resume form."
For flow cases, do not fail a case just because the model chose a different valid topology.
## App-specific rules
App prompts should focus on user-visible behavior:
- what the UI should let the user do
- what should persist
- what backend behavior is needed
Avoid prompting in terms of React structure, component names, or implementation unless the case is specifically about editing an existing app.
## CLI-specific rules
CLI prompts can be more explicit about paths and file names because real CLI users often do specify them.
Still, avoid benchmark phrasing. The prompt should read like a repo task, not a harness instruction.
When relevant, ask the assistant to tell the user which `wmill` commands to run next. That is part of the benchmarked behavior.
## Deterministic validation
Use deterministic validation only for hard failures such as:
- missing required files
- unexpected extra files when the prompt says not to create them
- syntax errors
- unresolved flow refs
- missing required special modules or suspend config
- obvious artifact corruption
Do not use deterministic validation to enforce one preferred implementation for broad creation tasks.
Examples of bad hard checks:
- exact step topology for a creation flow
- exact branch structure when the prompt only asked for routing behavior
- exact input shape when multiple reasonable shapes are acceptable
## Judge checklist
Every non-trivial case should have a `judgeChecklist`.
The checklist should capture:
- the user-visible behavior that must be present
- important constraints
- key completion criteria
The checklist should not duplicate low-level implementation details unless they are truly required by the task.
Good checklist items:
- "the flow calculates the order total with 8% tax"
- "the app persists recipes appropriately for a raw Windmill app"
- "the flow reuses the existing workspace script instead of rewriting the logic"
Bad checklist items:
- "uses `branchone`"
- "contains a `rawscript` node"
## When to use `expected`
Use `expected` fixtures when the case is structure-sensitive, for example:
- exact file creation
- exact script content
- modification cases where a specific file must change in a specific way
- cases where preserving an existing structure is part of the requirement
Do not use a full `expected` artifact as the semantic oracle for broad creation tasks when multiple valid outputs should pass.
## When to use `initial`
Use `initial` when the benchmark is about:
- editing an existing artifact
- reusing existing workspace assets
- preserving existing behavior while adding a change
If the case is greenfield, prefer no `initial`.
## Case design ladder
Prefer suites that get gradually harder:
1. trivial create case
2. realistic create case
3. reuse-existing-assets case
4. modification case
5. refactor case
6. edge-case or niche product behavior
The last cases in a suite should cover unusual or product-specific behavior.
## Anti-patterns
Avoid these:
- benchmark framing in prompts
- over-specified internal topology for creation tasks
- judge checklists that just restate implementation details
- deterministic validation that encodes one preferred solution
- fixtures that are so minimal or brittle that they create false negatives
## Before adding a case
Ask:
1. Would a real user plausibly write this prompt?
2. If the model solves it in a different valid way, would the case still pass?
3. Are the hard deterministic checks only catching objectively broken output?
4. Does the `judgeChecklist` describe the real success criteria?
5. If this case fails, will the reason be understandable from the saved artifacts?
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+219
View File
@@ -0,0 +1,219 @@
# AI Evals
Small benchmark runner for the four Windmill AI generation modes:
- `cli`
- `flow`
- `script`
- `app`
The benchmark always tests the current production prompts, tools, and guidance in this checkout.
Each attempt runs:
1. the real production path
2. deterministic validation
3. LLM judging
## Install
```bash
cd ai_evals
bun install
```
Frontend modes also require frontend dependencies:
```bash
cd frontend
bun install
```
## Commands
List model aliases:
```bash
cd ai_evals
bun run cli -- models
```
List cases:
```bash
cd ai_evals
bun run cli -- cases
bun run cli -- cases flow
```
Run benchmarks:
```bash
cd ai_evals
bun run cli -- run flow
bun run cli -- run flow flow-test4-order-processing-loop --model opus
bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o
bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose
bun run cli -- run flow --record
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro --transport proxy
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview
bun run cli -- run cli bun-hello-script
```
Public CLI surface:
- `models`
- `cases [mode]`
- `run <mode> [caseIds...]`
`run` options:
- `--runs <n>`: repeat each case `n` times
- `--output <path>`: custom result JSON path
- `--model <alias>`: choose the model under test
- `--models <a,b,c>`: run the same cases sequentially against several model aliases
- `--transport <mode>`: frontend request transport (`direct` by default, `proxy` to exercise `/api/w/{workspace}/ai/proxy`)
- `--verbose`: stream assistant output for frontend runs
- `--record`: append a compact tracked summary line to `ai_evals/history/<mode>.jsonl` for full-suite runs only
- `--backend-validation <mode>`: optional backend smoke validation (`off` or `preview`) for `script` and `flow` evals
## Models
Use `bun run cli -- models` to see the current aliases.
Today:
- `haiku`
- `sonnet`
- `opus`
- `4o`
- `gemini-flash`
- `gemini-pro`
- `gemini-3-flash-preview`
- `gemini-3.1-pro-preview`
Notes:
- the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5`
- frontend modes (`flow`, `script`, `app`) can use Anthropic, OpenAI, and Gemini-backed aliases
- `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`
## Case Format
Cases live in one YAML file per mode under `ai_evals/cases/`.
Minimal shape:
```yaml
- id: flow-test0-sum-two-numbers
prompt: |-
Create a flow that takes two numbers, `a` and `b`, and returns their sum.
initial: ai_evals/fixtures/...
expected: ai_evals/fixtures/...
```
Optional fields:
- `initial`: starting state fixture
- `expected`: expected artifact fixture
- `validate`: extra deterministic validation rules
- `runtime.backendPreview`: optional real backend preview config for smoke validation
For `flow` mode, `validate` can express requirements such as:
- accepted input schema shapes
- required `results.*` reference validity
- required module/code/input characteristics
For `app` mode, `validate` can express narrow hard requirements such as:
- required frontend file paths or backend runnable keys
- minimum backend runnable counts
- required backend runnable types
- minimum datatable / datatable-table counts
- specific required datatable tables
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
existing scripts and flows. That lets the real `search_workspace` and
`get_runnable_details` tools discover reusable workspace runnables during evals.
If `--backend-validation preview` is enabled:
- `script` evals run a real backend script preview in an isolated temp workspace
- `flow` evals run a real backend flow preview only for cases that define `runtime.backendPreview`
- `flow` cases with `initial.workspace` fixtures seed those scripts and flows into the preview workspace before preview
- when `WMILL_AI_EVAL_BACKEND_WORKSPACE` is set, `ai_evals` treats that workspace as a dedicated test workspace, clears managed eval assets under `f/evals/*` before each preview run, and then reseeds the current case fixtures
Supported backend validation env vars:
- `WMILL_AI_EVAL_BACKEND_VALIDATION=preview`
- `WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000`
- `WMILL_AI_EVAL_BACKEND_EMAIL=admin@windmill.dev`
- `WMILL_AI_EVAL_BACKEND_PASSWORD=changeme`
- `WMILL_AI_EVAL_BACKEND_WORKSPACE=integration-tests` to reuse an existing workspace on CE installs with low workspace limits
- `WMILL_AI_EVAL_KEEP_WORKSPACES=1`
- `WMILL_AI_EVAL_WORKSPACE_PREFIX=ai-evals`
Frontend proxy transport uses the same backend auth/workspace env vars.
When `--transport proxy` is set:
- `ai_evals` creates or reuses a backend workspace
- it upserts a provider resource under `f/evals/ai/<provider>`
- frontend requests go through `/api/w/{workspace}/ai/proxy`
- result JSON and history records include `transport` so direct vs proxy runs stay distinguishable
## Results And Artifacts
Every run writes:
- a summary JSON under `ai_evals/results/`
- generated artifacts in a sibling directory
If `--record` is used, the CLI also appends one compact JSON line to:
- `ai_evals/history/flow.jsonl`
- `ai_evals/history/script.jsonl`
- `ai_evals/history/app.jsonl`
- `ai_evals/history/cli.jsonl`
Each recorded line contains:
- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `transport`, `judgeModel`)
- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`)
- average token usage (`averageTokenUsagePerAttempt`)
- per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate)
- `failedCaseIds`
Example:
- summary: `ai_evals/results/2026-04-09T09-40-33.051Z__flow.json`
- artifacts: `ai_evals/results/2026-04-09T09-40-33.051Z__flow/`
Typical artifacts by mode:
- `flow`: `flow.json`
- `script`: `script.json` plus the generated script file
- `app`: `app.json` plus frontend/backend files
- `cli`: `assistant-output.txt`, `trace.json`, `wmill-invocations.jsonl`, plus generated workspace files
- backend-validated attempts also include `backend-preview.json`
## Layout
- `cases/`: one YAML file per mode
- `fixtures/`: initial and expected fixtures
- `core/`: shared loading, model resolution, validation, judging, and result writing
- `modes/`: one runner per mode
- `history/`: optional tracked pass-rate history written by `run --record`, one JSONL file per mode
- `results/`: local benchmark output and artifacts
## Notes
- Frontend modes reuse the production frontend chat code through the Vitest bridge.
- CLI mode creates an isolated workspace, writes the current checkout guidance into it, and benchmarks the real skills / `AGENTS.md` flow.
- CLI mode now also records a structured trace of invoked skills, tool calls, proposed `wmill` commands, and any attempted `wmill` executions.
- Frontend progress streams live while the benchmark is running.
- Deterministic validators should stay focused on real correctness constraints, not one exact implementation shape.
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it } from "bun:test";
import {
anthropicUsageToBenchmarkTokenUsage,
extractCliResultTokenUsage,
extractProposedWmillCommands,
parseWmillInvocationLog,
} from "./runtime";
describe("anthropicUsageToBenchmarkTokenUsage", () => {
it("includes cache tokens in prompt usage", () => {
expect(
anthropicUsageToBenchmarkTokenUsage({
input_tokens: 120,
output_tokens: 45,
cache_creation_input_tokens: 30,
cache_read_input_tokens: 5,
})
).toEqual({
prompt: 155,
completion: 45,
total: 200,
});
});
it("returns null when usage is absent", () => {
expect(anthropicUsageToBenchmarkTokenUsage(null)).toBeNull();
});
});
describe("extractCliResultTokenUsage", () => {
it("reads aggregate usage from the SDK result event", () => {
expect(
extractCliResultTokenUsage({
type: "result",
usage: {
input_tokens: 400,
output_tokens: 120,
cache_creation_input_tokens: 50,
cache_read_input_tokens: 25,
},
})
).toEqual({
prompt: 475,
completion: 120,
total: 595,
});
});
it("falls back to modelUsage when aggregate usage is unavailable", () => {
expect(
extractCliResultTokenUsage({
type: "result",
modelUsage: {
opus: {
inputTokens: 200,
outputTokens: 60,
cacheCreationInputTokens: 10,
cacheReadInputTokens: 5,
},
haiku: {
inputTokens: 80,
outputTokens: 20,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 15,
},
},
})
).toEqual({
prompt: 310,
completion: 80,
total: 390,
});
});
});
describe("extractProposedWmillCommands", () => {
it("extracts proposed commands from bullets, code blocks, and inline code", () => {
expect(
extractProposedWmillCommands(`
Next:
- \`wmill generate-metadata --yes\`
- wmill sync push
You can inspect failures with \`wmill job logs 123\`.
`)
).toEqual([
"wmill generate-metadata --yes",
"wmill sync push",
"wmill job logs 123",
]);
});
it("extracts inline prose commands that are not wrapped in backticks", () => {
expect(
extractProposedWmillCommands(
"The first command is wmill sync pull before you edit locally."
)
).toEqual(["wmill sync pull"]);
});
it("extracts multiple inline prose commands from a single sentence", () => {
expect(
extractProposedWmillCommands(
"Run wmill generate-metadata and then wmill sync push when you are ready."
)
).toEqual(["wmill generate-metadata", "wmill sync push"]);
});
it("ignores negated command mentions", () => {
expect(
extractProposedWmillCommands(
"Do not run `wmill sync push`. Instead run `wmill sync pull` first."
)
).toEqual(["wmill sync pull"]);
});
});
describe("parseWmillInvocationLog", () => {
it("parses stubbed wmill invocations into structured records", () => {
expect(
parseWmillInvocationLog(`noise
__WMILL_BENCHMARK__
2026-04-21T12:00:00+00:00
/tmp/workspace
2
generate-metadata
--yes
__WMILL_BENCHMARK__
2026-04-21T12:00:05+00:00
/tmp/workspace
3
sync
push
--dry-run
`)
).toEqual([
{
argv: ["generate-metadata", "--yes"],
cwd: "/tmp/workspace",
timestamp: "2026-04-21T12:00:00+00:00",
},
{
argv: ["sync", "push", "--dry-run"],
cwd: "/tmp/workspace",
timestamp: "2026-04-21T12:00:05+00:00",
},
]);
});
});
+532
View File
@@ -0,0 +1,532 @@
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "path";
import { fileURLToPath } from "url";
import { getCliEvalModel, resolveEvalModel, type CliEvalModelConfig } from "../../core/models";
import type {
BenchmarkTokenUsage,
CliToolInvocation,
CliTrace,
CliWmillInvocation,
} from "../../core/types";
export type ToolInvocation = CliToolInvocation;
export interface PromptRunResult {
output: string;
durationMs: number;
tokenUsage: BenchmarkTokenUsage | null;
trace: CliTrace;
}
interface AnthropicUsageLike {
input_tokens?: number | null;
output_tokens?: number | null;
cache_creation_input_tokens?: number | null;
cache_read_input_tokens?: number | null;
}
interface AnthropicModelUsageLike {
inputTokens?: number | null;
outputTokens?: number | null;
cacheCreationInputTokens?: number | null;
cacheReadInputTokens?: number | null;
}
interface CliResultMessageLike {
type?: string;
usage?: AnthropicUsageLike | null;
modelUsage?: Record<string, AnthropicModelUsageLike> | null;
}
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
export const DEFAULT_CLI_EVAL_MODEL: CliEvalModelConfig = getCliEvalModel(resolveEvalModel("cli"));
const WMILL_STUB_DIR_NAME = ".wmill-benchmark-bin";
const WMILL_LOG_FILE_NAME = ".wmill-benchmark-wmill-invocations.log";
const WMILL_LOG_MARKER = "__WMILL_BENCHMARK__";
const NEGATED_COMMAND_PREFIX = /(?:^|\b)(?:do not|don't|dont|never|instead of)\s+(?:run|use)?\s*$/i;
const COMMAND_STOP_WORDS = new Set([
"and",
"before",
"after",
"then",
"instead",
"otherwise",
"because",
"so",
"if",
"when",
"while",
"once",
]);
const COMMAND_STOP_TOKENS = new Set(["-", "", "—", "|"]);
export function getGeneratedSkillsSource(): string {
return join(REPO_ROOT, "system_prompts", "auto-generated", "skills");
}
export function anthropicUsageToBenchmarkTokenUsage(
usage: AnthropicUsageLike | null | undefined
): BenchmarkTokenUsage | null {
if (!usage) {
return null;
}
const prompt =
(usage.input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0);
const completion = usage.output_tokens ?? 0;
return {
prompt,
completion,
total: prompt + completion,
};
}
export function extractCliResultTokenUsage(message: unknown): BenchmarkTokenUsage | null {
if (!message || typeof message !== "object") {
return null;
}
const resultMessage = message as CliResultMessageLike;
if (resultMessage.type !== "result") {
return null;
}
const usage = anthropicUsageToBenchmarkTokenUsage(resultMessage.usage);
if (usage) {
return usage;
}
if (!resultMessage.modelUsage || typeof resultMessage.modelUsage !== "object") {
return null;
}
let prompt = 0;
let completion = 0;
let sawModelUsage = false;
for (const modelUsage of Object.values(resultMessage.modelUsage)) {
if (!modelUsage || typeof modelUsage !== "object") {
continue;
}
prompt +=
(modelUsage.inputTokens ?? 0) +
(modelUsage.cacheCreationInputTokens ?? 0) +
(modelUsage.cacheReadInputTokens ?? 0);
completion += modelUsage.outputTokens ?? 0;
sawModelUsage = true;
}
if (!sawModelUsage) {
return null;
}
return {
prompt,
completion,
total: prompt + completion,
};
}
export async function runPromptAndCapture(
prompt: string,
cwd: string,
maxTurns: number = 3,
modelConfig: CliEvalModelConfig = DEFAULT_CLI_EVAL_MODEL
): Promise<PromptRunResult> {
const toolsUsed: ToolInvocation[] = [];
const skillsInvoked: string[] = [];
const bashCommands: string[] = [];
let output = "";
let assistantMessageCount = 0;
let tokenUsage: BenchmarkTokenUsage | null = null;
const startedAt = Date.now();
const stubBinDir = join(cwd, WMILL_STUB_DIR_NAME);
const wmillLogPath = join(cwd, WMILL_LOG_FILE_NAME);
const options: Options = {
cwd,
model: modelConfig.model,
maxTurns,
settingSources: ["project"],
allowedTools: ["Skill", "Read", "Glob", "Grep", "Bash", "Write", "Edit"],
env: {
...getQueryEnv(),
PATH: process.env.PATH ? `${stubBinDir}${delimiter}${process.env.PATH}` : stubBinDir,
WMILL_BENCHMARK_LOG_PATH: wmillLogPath,
},
};
await installWmillStub(stubBinDir);
for await (const message of query({ prompt, options })) {
if (message.type === "assistant") {
assistantMessageCount += 1;
const content = message.message?.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "tool_use") {
const input = normalizeToolInput(block.input);
toolsUsed.push({
tool: block.name,
input,
timestamp: Date.now()
});
if (block.name === "Skill") {
const skillInput = input as { skill?: string };
if (skillInput.skill) {
pushUnique(skillsInvoked, skillInput.skill);
}
}
if (block.name === "Bash") {
for (const command of extractBashCommands(input)) {
pushUnique(bashCommands, command);
}
}
} else if (block.type === "text") {
output += block.text;
}
}
}
} else if (message.type === "result") {
const resultMessage = message as { result?: string };
tokenUsage = extractCliResultTokenUsage(message) ?? tokenUsage;
if (typeof resultMessage.result === "string") {
output += resultMessage.result;
}
}
}
const proposedCommands = extractProposedWmillCommands(output);
const wmillInvocations = await readWmillInvocationLog(wmillLogPath);
return {
output,
durationMs: Date.now() - startedAt,
tokenUsage,
trace: {
toolsUsed,
skillsInvoked,
assistantMessageCount,
bashCommands,
proposedCommands,
executedWmillCommands: wmillInvocations.map(formatExecutedWmillCommand),
wmillInvocations,
firstMutationToolIndex: getFirstMutationToolIndex(toolsUsed),
},
};
}
export function wasSkillInvoked(result: PromptRunResult, skillName: string): boolean {
return result.trace.skillsInvoked.some((skill) => skill === skillName);
}
export function wasToolUsed(result: PromptRunResult, toolName: string): boolean {
return result.trace.toolsUsed.some((tool) => tool.tool === toolName);
}
export function formatCliRunModelLabel(modelConfig: CliEvalModelConfig): string {
return `${modelConfig.provider}:${modelConfig.model}`;
}
export function getToolInputs(
result: PromptRunResult,
toolName: string
): Record<string, unknown>[] {
return result.trace.toolsUsed
.filter((tool) => tool.tool === toolName)
.map((tool) => tool.input);
}
export function extractProposedWmillCommands(output: string): string[] {
const commands: string[] = [];
for (const line of output.split(/\r?\n/)) {
for (const command of extractInlineBacktickCommands(line)) {
pushUnique(commands, command);
}
for (const command of extractInlineProseCommands(line.replace(/^\s*(?:[-*]|\d+\.)\s*/, ""))) {
pushUnique(commands, command);
}
}
return commands;
}
export function parseWmillInvocationLog(raw: string): CliWmillInvocation[] {
const entries: CliWmillInvocation[] = [];
const lines = raw.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
if (lines[index] !== WMILL_LOG_MARKER) {
continue;
}
const timestamp = lines[index + 1] ?? "";
const cwd = lines[index + 2] ?? "";
const argCount = Number.parseInt(lines[index + 3] ?? "", 10);
if (!Number.isFinite(argCount) || argCount < 0) {
continue;
}
const start = index + 4;
const argv = lines.slice(start, start + argCount);
entries.push({ argv, cwd, timestamp });
index = start + argCount - 1;
}
return entries;
}
async function installWmillStub(binDir: string): Promise<void> {
await mkdir(binDir, { recursive: true });
const stubPath = join(binDir, "wmill");
const script = `#!/usr/bin/env bash
set -euo pipefail
{
printf '${WMILL_LOG_MARKER}\\n'
date -u +"%Y-%m-%dT%H:%M:%SZ"
printf '%s\\n' "$PWD"
printf '%s\\n' "$#"
printf '%s\\n' "$@"
} >> "\${WMILL_BENCHMARK_LOG_PATH:?}"
printf 'wmill benchmark stub: do not execute Windmill CLI commands during ai_evals; describe them in the final response instead.\\n' >&2
exit 97
`;
await writeFile(stubPath, script, "utf8");
await chmod(stubPath, 0o755);
}
async function readWmillInvocationLog(logPath: string): Promise<CliWmillInvocation[]> {
const raw = await readFile(logPath, "utf8").catch(() => null);
if (!raw) {
return [];
}
return parseWmillInvocationLog(raw);
}
function getQueryEnv(): Record<string, string> {
return Object.fromEntries(
Object.entries(process.env).flatMap(([key, value]) =>
typeof value === "string" ? [[key, value]] : []
)
);
}
function normalizeToolInput(input: unknown): Record<string, unknown> {
if (input && typeof input === "object" && !Array.isArray(input)) {
return input as Record<string, unknown>;
}
if (typeof input === "string") {
return { raw: input };
}
return {};
}
function extractBashCommands(input: Record<string, unknown>): string[] {
const commands: string[] = [];
for (const key of ["command", "cmd", "script", "raw"]) {
const value = input[key];
if (typeof value === "string") {
for (const line of value.split(/\r?\n/)) {
const command = normalizeCommandCandidate(line);
if (command) {
pushUnique(commands, command);
}
}
}
}
return commands;
}
function extractInlineBacktickCommands(line: string): string[] {
const commands: string[] = [];
const regex = /`(wmill [^`\n]+)`/g;
let match: RegExpExecArray | null = null;
while ((match = regex.exec(line)) !== null) {
if (hasNegatedCommandPrefix(line.slice(0, match.index))) {
continue;
}
const command = normalizeCommandCandidate(match[1]);
if (command) {
pushUnique(commands, command);
}
}
return commands;
}
function extractInlineProseCommands(line: string): string[] {
const commands: string[] = [];
let searchFrom = 0;
while (true) {
const inlineIndex = line.toLowerCase().indexOf("wmill ", searchFrom);
if (inlineIndex === -1) {
return commands;
}
if (!hasNegatedCommandPrefix(line.slice(0, inlineIndex))) {
const command = extractInlineProseCommandAt(line, inlineIndex);
if (command) {
pushUnique(commands, command);
}
}
searchFrom = inlineIndex + "wmill ".length;
}
}
function extractInlineProseCommandAt(line: string, startIndex: number): string | null {
const tokens = ["wmill"];
let cursor = startIndex + "wmill".length;
while (cursor < line.length) {
while (cursor < line.length && /\s/.test(line[cursor]!)) {
cursor += 1;
}
if (cursor >= line.length) {
break;
}
const current = line[cursor]!;
if ("`.,;:()[]{}".includes(current)) {
break;
}
const token = readCommandToken(line, cursor);
if (!token) {
break;
}
if (COMMAND_STOP_WORDS.has(token.value.toLowerCase())) {
break;
}
if (COMMAND_STOP_TOKENS.has(token.value)) {
break;
}
tokens.push(token.value);
cursor = token.nextIndex;
}
if (tokens.length <= 1) {
return null;
}
return normalizeCommandCandidate(tokens.join(" "));
}
function readCommandToken(
line: string,
startIndex: number
): { value: string; nextIndex: number } | null {
const firstChar = line[startIndex]!;
if (firstChar === `"` || firstChar === `'`) {
const endIndex = line.indexOf(firstChar, startIndex + 1);
const nextIndex = endIndex === -1 ? line.length : endIndex + 1;
return {
value: line.slice(startIndex, nextIndex),
nextIndex,
};
}
if (firstChar === "<") {
const endIndex = line.indexOf(">", startIndex + 1);
const nextIndex = endIndex === -1 ? line.length : endIndex + 1;
return {
value: line.slice(startIndex, nextIndex),
nextIndex,
};
}
let endIndex = startIndex;
while (endIndex < line.length && !/[\s`.,;:()[\]{}#]/.test(line[endIndex]!)) {
endIndex += 1;
}
if (endIndex === startIndex) {
return null;
}
return {
value: line.slice(startIndex, endIndex),
nextIndex: endIndex,
};
}
function hasNegatedCommandPrefix(prefix: string): boolean {
const normalizedPrefix = prefix
.toLowerCase()
.replace(/[`"'“”‘’]/g, " ")
.replace(/\s+/g, " ")
.trimEnd();
return NEGATED_COMMAND_PREFIX.test(normalizedPrefix);
}
function normalizeCommandCandidate(value: string): string | null {
const trimmed = value.trim().replace(/^`|`$/g, "");
if (!trimmed) {
return null;
}
const normalized = trimmed
.replace(/\s+/g, " ")
.replace(/[`.;:,]+$/g, "")
.trim();
return normalized.length > 0 ? normalized : null;
}
function formatExecutedWmillCommand(entry: CliWmillInvocation): string {
return ["wmill", ...entry.argv].join(" ").trim();
}
function getFirstMutationToolIndex(toolsUsed: ToolInvocation[]): number | null {
for (const [index, tool] of toolsUsed.entries()) {
if (tool.tool === "Write" || tool.tool === "Edit") {
return index;
}
if (tool.tool === "Bash" && extractBashCommands(tool.input).some(isLikelyMutatingBashCommand)) {
return index;
}
}
return null;
}
function isLikelyMutatingBashCommand(command: string): boolean {
return (
/\b(?:mkdir|touch|rm|mv|cp|install|tee)\b/.test(command) ||
/\b(?:cat|echo|printf)\b.*(?:>|>>|\|\s*tee\b)/.test(command) ||
/\bsed\s+-i\b/.test(command) ||
/\bperl\s+-pi\b/.test(command) ||
/\bwmill\b/.test(command)
);
}
function pushUnique(values: string[], value: string): void {
if (!values.includes(value)) {
values.push(value);
}
}
@@ -0,0 +1,246 @@
import { afterEach, describe, expect, it } from 'bun:test'
import type { BackendValidationSettings } from '../../core/backendValidation'
import { BackendPreviewClient } from './backendPreview'
const ORIGINAL_FETCH = globalThis.fetch
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH
})
describe('BackendPreviewClient', () => {
it('updates an existing seeded script on path conflict and waits for deployment', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = []
globalThis.fetch = mockFetch(
requests,
textResponse(200, 'token'),
textResponse(200, ''),
textResponse(400, 'Path conflict for f/evals/add_two_numbers with non-archived hash 123'),
jsonResponse(200, { hash: '123' }),
textResponse(200, '456'),
jsonResponse(200, { lock: 'script.lock', lock_error_logs: null })
)
const client = new BackendPreviewClient(
buildSettings({ baseUrl: 'http://backend.test/script-upsert' })
)
await client.createScript({
workspaceId: 'test',
path: 'f/evals/add_two_numbers',
summary: 'Add two numbers',
content: 'export async function main(a: number, b: number) { return a + b }',
language: 'bun'
})
expect(requests.map((entry) => entry.url)).toEqual([
'http://backend.test/script-upsert/api/auth/login',
'http://backend.test/script-upsert/api/w/test/folders/create',
'http://backend.test/script-upsert/api/w/test/scripts/create',
'http://backend.test/script-upsert/api/w/test/scripts/get/p/f/evals/add_two_numbers',
'http://backend.test/script-upsert/api/w/test/scripts/create',
'http://backend.test/script-upsert/api/w/test/scripts/deployment_status/h/456'
])
const updateRequest = requests[4]
expect(updateRequest.init?.method).toBe('POST')
expect(JSON.parse(String(updateRequest.init?.body))).toMatchObject({
path: 'f/evals/add_two_numbers',
parent_hash: '123',
language: 'bun'
})
})
it('updates an existing seeded flow on create conflict', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = []
globalThis.fetch = mockFetch(
requests,
textResponse(200, 'token'),
textResponse(200, ''),
textResponse(400, 'Flow f/evals/add_numbers_flow already exists'),
textResponse(200, '')
)
const client = new BackendPreviewClient(
buildSettings({ baseUrl: 'http://backend.test/flow-upsert' })
)
await client.createFlow({
workspaceId: 'test',
path: 'f/evals/add_numbers_flow',
summary: 'Add numbers',
value: { modules: [] }
})
expect(requests.map((entry) => entry.url)).toEqual([
'http://backend.test/flow-upsert/api/auth/login',
'http://backend.test/flow-upsert/api/w/test/folders/create',
'http://backend.test/flow-upsert/api/w/test/flows/create',
'http://backend.test/flow-upsert/api/w/test/flows/update/f/evals/add_numbers_flow'
])
const updateRequest = requests[3]
expect(updateRequest.init?.method).toBe('POST')
expect(JSON.parse(String(updateRequest.init?.body))).toMatchObject({
path: 'f/evals/add_numbers_flow',
value: { modules: [] }
})
})
it('serializes shared-workspace validations inside the overridden workspace', async () => {
globalThis.fetch = async (input) => {
const url = String(input)
if (url.endsWith('/api/auth/login')) {
return textResponse(200, 'token')
}
if (url.endsWith('/api/workspaces/exists')) {
return textResponse(200, 'true')
}
if (url.endsWith('/api/w/shared-preview/flows/list_paths')) {
return jsonResponse(200, [])
}
if (url.endsWith('/api/w/shared-preview/scripts/list_paths')) {
return jsonResponse(200, [])
}
throw new Error(`Unexpected fetch: ${url}`)
}
const client = new BackendPreviewClient(
buildSettings({
baseUrl: 'http://backend.test/shared-lock',
workspaceOverride: 'shared-preview'
})
)
const order: string[] = []
let releaseFirst: (() => void) | undefined
let notifyFirstStart: (() => void) | undefined
const firstStarted = new Promise<void>((resolve) => {
notifyFirstStart = resolve
})
const first = client.withWorkspace('flow-test1', 1, async () => {
order.push('first:start')
notifyFirstStart?.()
await new Promise<void>((resolve) => {
releaseFirst = resolve
})
order.push('first:end')
})
const second = client.withWorkspace('flow-test2', 1, async () => {
order.push('second:start')
order.push('second:end')
})
await firstStarted
expect(order).toEqual(['first:start'])
releaseFirst?.()
await Promise.all([first, second])
expect(order).toEqual(['first:start', 'first:end', 'second:start', 'second:end'])
})
it('clears managed shared-workspace assets before preview runs', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = []
globalThis.fetch = mockFetch(
requests,
textResponse(200, 'token'),
textResponse(200, 'true'),
jsonResponse(200, ['f/evals/old_subflow', 'u/admin/keep_flow']),
textResponse(200, ''),
jsonResponse(200, ['f/evals/old_script', 'f/shared/keep_script']),
textResponse(200, '')
)
const client = new BackendPreviewClient(
buildSettings({
baseUrl: 'http://backend.test/shared-cleanup',
workspaceOverride: 'shared-preview'
})
)
await client.withWorkspace('flow-test1', 1, async () => undefined)
expect(requests.map((entry) => entry.url)).toEqual([
'http://backend.test/shared-cleanup/api/auth/login',
'http://backend.test/shared-cleanup/api/workspaces/exists',
'http://backend.test/shared-cleanup/api/w/shared-preview/flows/list_paths',
'http://backend.test/shared-cleanup/api/w/shared-preview/flows/delete/f/evals/old_subflow',
'http://backend.test/shared-cleanup/api/w/shared-preview/scripts/list_paths',
'http://backend.test/shared-cleanup/api/w/shared-preview/scripts/delete/p/f/evals/old_script'
])
})
it('retries login after a cached login failure', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = []
globalThis.fetch = mockFetch(
requests,
textResponse(503, 'backend starting'),
textResponse(200, 'token'),
textResponse(200, 'true'),
jsonResponse(200, []),
jsonResponse(200, [])
)
const client = new BackendPreviewClient(
buildSettings({
baseUrl: 'http://backend.test/login-retry',
workspaceOverride: 'shared-preview'
})
)
await expect(client.withWorkspace('flow-test1', 1, async () => undefined)).rejects.toThrow(
'login for backend validation failed'
)
await expect(client.withWorkspace('flow-test1', 1, async () => 'ok')).resolves.toBe('ok')
expect(
requests.filter((entry) => entry.url === 'http://backend.test/login-retry/api/auth/login')
).toHaveLength(2)
})
})
function buildSettings(
overrides: Partial<BackendValidationSettings> = {}
): BackendValidationSettings {
return {
mode: 'preview',
baseUrl: 'http://backend.test/default',
email: 'admin@windmill.dev',
password: 'changeme',
keepWorkspaces: true,
workspacePrefix: 'ai-evals',
pollIntervalMs: 1,
maxWaitMs: 50,
...overrides
}
}
function mockFetch(
requests: Array<{ url: string; init?: RequestInit }>,
...responses: Response[]
): typeof fetch {
const queue = [...responses]
return async (input, init) => {
const url = String(input)
requests.push({ url, init })
const next = queue.shift()
if (!next) {
throw new Error(`Unexpected fetch: ${url}`)
}
return next
}
}
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
})
}
function textResponse(status: number, body: string): Response {
return new Response(body, { status })
}
@@ -0,0 +1,502 @@
import { randomUUID } from 'node:crypto'
import type { BackendValidationSettings } from '../../core/backendValidation'
interface CompletedJobResultMaybe {
completed: boolean
result: unknown
success?: boolean
started?: boolean
}
interface ScriptDeploymentStatus {
lock?: unknown
lock_error_logs?: string | null
}
export interface CompletedPreviewJob {
id: string
success: boolean
result: unknown
logs?: string | null
raw: Record<string, unknown>
}
const tokenCache = new Map<string, Promise<string>>()
const sharedWorkspaceQueue = new Map<string, Promise<void>>()
const managedSharedWorkspacePrefixes = ['f/evals/']
export class BackendPreviewClient {
constructor(private readonly settings: BackendValidationSettings) {}
async withWorkspace<T>(
caseId: string,
attempt: number,
body: (workspaceId: string) => Promise<T>
): Promise<T> {
const workspaceId =
this.settings.workspaceOverride ??
buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt)
const run = async () => {
await this.ensureWorkspace(workspaceId)
if (this.settings.workspaceOverride) {
await this.clearManagedSharedWorkspaceAssets(workspaceId)
}
try {
return await body(workspaceId)
} finally {
if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) {
await this.deleteWorkspace(workspaceId).catch(() => undefined)
}
}
}
if (this.settings.workspaceOverride) {
return await withSharedWorkspaceLock(workspaceId, run)
}
return await run()
}
async createScript(input: {
workspaceId: string
path: string
summary: string
description?: string
schema?: Record<string, unknown>
content: string
language: string
}): Promise<void> {
await this.ensureFolderForPath(input.workspaceId, input.path)
const payload = {
path: input.path,
summary: input.summary,
description: input.description ?? '',
content: input.content,
schema: input.schema ?? { type: 'object', properties: {}, required: [] },
is_template: false,
language: input.language,
kind: 'script'
}
const response = await this.request(`/w/${encodeURIComponent(input.workspaceId)}/scripts/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (response.ok) {
await this.waitForScriptDeployment(input.workspaceId, input.path, (await response.text()).trim())
return
}
const message = await response.text()
if (!isConflictMessage(message)) {
throw new Error(`create script ${input.path} failed: ${response.status} ${response.statusText} - ${message}`)
}
const currentScript = await this.getScriptByPath(input.workspaceId, input.path)
const currentHash = readStringField(currentScript, 'hash', `script ${input.path}`)
const updateResponse = await this.request(
`/w/${encodeURIComponent(input.workspaceId)}/scripts/create`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
parent_hash: currentHash
})
}
)
await expectOk(updateResponse, `update script ${input.path}`)
await this.waitForScriptDeployment(input.workspaceId, input.path, (await updateResponse.text()).trim())
}
async createFlow(input: {
workspaceId: string
path: string
summary: string
description?: string
schema?: Record<string, unknown>
value: Record<string, unknown>
}): Promise<void> {
await this.ensureFolderForPath(input.workspaceId, input.path)
const payload = {
path: input.path,
summary: input.summary,
description: input.description ?? '',
schema: input.schema ?? { type: 'object', properties: {}, required: [] },
value: input.value
}
const response = await this.request(`/w/${encodeURIComponent(input.workspaceId)}/flows/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (response.ok) {
return
}
const message = await response.text()
if (!isConflictMessage(message)) {
throw new Error(`create flow ${input.path} failed: ${response.status} ${response.statusText} - ${message}`)
}
const updateResponse = await this.request(
`/w/${encodeURIComponent(input.workspaceId)}/flows/update/${input.path}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}
)
await expectOk(updateResponse, `update flow ${input.path}`)
}
async runScriptPreview(input: {
workspaceId: string
content: string
args: Record<string, unknown>
language: string
path?: string
timeoutSeconds?: number
}): Promise<CompletedPreviewJob> {
const response = await this.request(
withQuery(`/w/${encodeURIComponent(input.workspaceId)}/jobs/run/preview`, {
timeout: input.timeoutSeconds
}),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: input.content,
args: input.args,
language: input.language,
path: input.path
})
}
)
await expectOk(response, 'start script preview')
const jobId = (await response.text()).trim()
return await this.waitForCompletedJob(input.workspaceId, jobId)
}
async runFlowPreview(input: {
workspaceId: string
value: Record<string, unknown>
args: Record<string, unknown>
timeoutSeconds?: number
path?: string
}): Promise<CompletedPreviewJob> {
const response = await this.request(
withQuery(`/w/${encodeURIComponent(input.workspaceId)}/jobs/run/preview_flow`, {
timeout: input.timeoutSeconds
}),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
value: input.value,
args: input.args,
path: input.path
})
}
)
await expectOk(response, 'start flow preview')
const jobId = (await response.text()).trim()
return await this.waitForCompletedJob(input.workspaceId, jobId)
}
private async ensureWorkspace(workspaceId: string): Promise<void> {
const existsResponse = await this.request('/workspaces/exists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: workspaceId })
})
await expectOk(existsResponse, `check workspace ${workspaceId}`)
if ((await existsResponse.text()).trim() === 'true') {
return
}
const createResponse = await this.request('/workspaces/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: workspaceId, name: workspaceId })
})
try {
await expectOk(createResponse, `create workspace ${workspaceId}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('maximum number of workspaces')) {
throw new Error(
`${message}. Reuse an existing workspace with WMILL_AI_EVAL_BACKEND_WORKSPACE=<workspace-id>.`
)
}
throw error
}
}
private async deleteWorkspace(workspaceId: string): Promise<void> {
const response = await this.request(`/workspaces/delete/${encodeURIComponent(workspaceId)}`, {
method: 'DELETE'
})
await expectOk(response, `delete workspace ${workspaceId}`)
}
private async ensureFolderForPath(workspaceId: string, path: string): Promise<void> {
const folderName = extractFolderName(path)
if (!folderName) {
return
}
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/folders/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: folderName })
})
if (response.ok) {
return
}
const message = await response.text()
if (!message.toLowerCase().includes('already exists')) {
throw new Error(`Failed to create folder ${folderName}: ${message}`)
}
}
private async waitForCompletedJob(
workspaceId: string,
jobId: string
): Promise<CompletedPreviewJob> {
const deadline = Date.now() + this.settings.maxWaitMs
while (Date.now() < deadline) {
const maybeResponse = await this.request(
`/w/${encodeURIComponent(workspaceId)}/jobs_u/completed/get_result_maybe/${encodeURIComponent(jobId)}?get_started=false`
)
await expectOk(maybeResponse, `poll job ${jobId}`)
const maybeResult = (await maybeResponse.json()) as CompletedJobResultMaybe
if (maybeResult.completed) {
const completedResponse = await this.request(
`/w/${encodeURIComponent(workspaceId)}/jobs_u/completed/get/${encodeURIComponent(jobId)}`
)
await expectOk(completedResponse, `get completed job ${jobId}`)
const completedJob = (await completedResponse.json()) as Record<string, unknown>
return {
id: jobId,
success: Boolean(maybeResult.success),
result: maybeResult.result,
logs:
typeof completedJob.logs === 'string' || completedJob.logs === null
? (completedJob.logs as string | null)
: null,
raw: completedJob
}
}
await new Promise((resolve) => setTimeout(resolve, this.settings.pollIntervalMs))
}
throw new Error(`Timed out waiting for preview job ${jobId} to complete`)
}
private async getScriptByPath(workspaceId: string, path: string): Promise<Record<string, unknown>> {
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/scripts/get/p/${path}`)
await expectOk(response, `get script ${path}`)
return (await response.json()) as Record<string, unknown>
}
private async clearManagedSharedWorkspaceAssets(workspaceId: string): Promise<void> {
const flowPaths = await this.listFlowPaths(workspaceId)
for (const path of flowPaths.filter(isManagedSharedWorkspacePath)) {
await this.deleteFlowByPath(workspaceId, path)
}
const scriptPaths = await this.listScriptPaths(workspaceId)
for (const path of scriptPaths.filter(isManagedSharedWorkspacePath)) {
await this.deleteScriptByPath(workspaceId, path)
}
}
private async listFlowPaths(workspaceId: string): Promise<string[]> {
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/flows/list_paths`)
await expectOk(response, `list flows in workspace ${workspaceId}`)
return await response.json()
}
private async listScriptPaths(workspaceId: string): Promise<string[]> {
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/scripts/list_paths`)
await expectOk(response, `list scripts in workspace ${workspaceId}`)
return await response.json()
}
private async deleteFlowByPath(workspaceId: string, path: string): Promise<void> {
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/flows/delete/${path}`, {
method: 'DELETE'
})
await expectOk(response, `delete flow ${path}`)
}
private async deleteScriptByPath(workspaceId: string, path: string): Promise<void> {
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/scripts/delete/p/${path}`, {
method: 'POST'
})
await expectOk(response, `delete script ${path}`)
}
private async waitForScriptDeployment(
workspaceId: string,
path: string,
hash: string
): Promise<void> {
const deadline = Date.now() + this.settings.maxWaitMs
while (Date.now() < deadline) {
const response = await this.request(
`/w/${encodeURIComponent(workspaceId)}/scripts/deployment_status/h/${encodeURIComponent(hash)}`
)
await expectOk(response, `check deployment status for script ${path}`)
const deployment = (await response.json()) as ScriptDeploymentStatus
if (deployment.lock != null) {
return
}
if (deployment.lock_error_logs) {
throw new Error(`Script deployment failed for ${path}: ${deployment.lock_error_logs}`)
}
await new Promise((resolve) => setTimeout(resolve, this.settings.pollIntervalMs))
}
throw new Error(`Timed out waiting for script ${path} (${hash}) to deploy`)
}
private async request(path: string, init?: RequestInit): Promise<Response> {
const token = await this.getToken()
return await fetch(`${this.settings.baseUrl}/api${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init?.headers ?? {})
}
})
}
private async getToken(): Promise<string> {
const cacheKey = `${this.settings.baseUrl}|${this.settings.email}`
let tokenPromise = tokenCache.get(cacheKey)
if (!tokenPromise) {
tokenPromise = this.login().catch((error) => {
if (tokenCache.get(cacheKey) === tokenPromise) {
tokenCache.delete(cacheKey)
}
throw error
})
tokenCache.set(cacheKey, tokenPromise)
}
return await tokenPromise
}
private async login(): Promise<string> {
const response = await fetch(`${this.settings.baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: this.settings.email,
password: this.settings.password
})
})
await expectOk(response, 'login for backend validation')
return (await response.text()).trim()
}
}
async function withSharedWorkspaceLock<T>(workspaceId: string, body: () => Promise<T>): Promise<T> {
const previous = sharedWorkspaceQueue.get(workspaceId) ?? Promise.resolve()
let releaseCurrent: (() => void) | undefined
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve
})
const tail = previous.catch(() => undefined).then(() => current)
sharedWorkspaceQueue.set(workspaceId, tail)
await previous.catch(() => undefined)
try {
return await body()
} finally {
releaseCurrent?.()
if (sharedWorkspaceQueue.get(workspaceId) === tail) {
sharedWorkspaceQueue.delete(workspaceId)
}
}
}
function buildWorkspaceId(prefix: string, caseId: string, attempt: number): string {
const caseSlug = caseId
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 30)
const suffix = randomUUID().slice(0, 8)
return `${prefix}-${caseSlug || 'case'}-a${attempt}-${suffix}`
}
function extractFolderName(path: string): string | null {
if (!path.startsWith('f/')) {
return null
}
const segments = path.split('/').slice(1, -1)
return segments.length > 0 ? segments.join('/') : null
}
function withQuery(
path: string,
params: Record<string, string | number | undefined>
): string {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value === undefined) {
continue
}
query.set(key, String(value))
}
const suffix = query.toString()
return suffix ? `${path}?${suffix}` : path
}
async function expectOk(response: Response, context: string): Promise<void> {
if (response.ok) {
return
}
throw new Error(`${context} failed: ${response.status} ${response.statusText} - ${await response.text()}`)
}
function readStringField(
value: Record<string, unknown>,
field: string,
context: string
): string {
const candidate = value[field]
if (typeof candidate === 'string' && candidate.length > 0) {
return candidate
}
throw new Error(`${context} is missing string field ${field}`)
}
function isConflictMessage(message: string): boolean {
const normalized = message.toLowerCase()
return normalized.includes('already exists') || normalized.includes('path conflict')
}
function isManagedSharedWorkspacePath(path: string): boolean {
return managedSharedWorkspacePrefixes.some((prefix) => path.startsWith(prefix))
}
@@ -0,0 +1,127 @@
import { loadSelectedCases } from "../../core/cases";
import { resolveBackendValidationSettings } from "../../core/backendValidation";
import { resolveFrontendEvalTransportSettings } from "../../core/frontendTransport";
import {
formatRunModelLabel,
getFrontendEvalModel,
resolveEvalModel,
} from "../../core/models";
import { buildRunResult } from "../../core/results";
import { runSuite } from "../../core/runSuite";
import type { BenchmarkRunResult, ModeRunner } from "../../core/types";
import { emitFrontendBenchmarkProgress } from "./progress";
import { createAppModeRunner } from "../../modes/app";
import { createFlowModeRunner } from "../../modes/flow";
import { createScriptModeRunner } from "../../modes/script";
import { DEFAULT_JUDGE_MODEL } from "../../core/judge";
export type FrontendBenchmarkMode = "flow" | "app" | "script";
export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult> {
const mode = parseMode(process.env.WMILL_FRONTEND_AI_EVAL_MODE);
const caseIds = parseOptionalJsonStringArray(
process.env.WMILL_FRONTEND_AI_EVAL_CASE_IDS,
);
const runs = parsePositiveInteger(
process.env.WMILL_FRONTEND_AI_EVAL_RUNS,
"WMILL_FRONTEND_AI_EVAL_RUNS",
);
const emitProgress = process.env.WMILL_FRONTEND_AI_EVAL_PROGRESS === "1";
const verbose = process.env.WMILL_FRONTEND_AI_EVAL_VERBOSE === "1";
const model = resolveEvalModel(
mode,
process.env.WMILL_FRONTEND_AI_EVAL_MODEL,
);
const backendValidation = resolveBackendValidationSettings({
evalMode: mode,
requestedMode: process.env.WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION,
});
const transportSettings = resolveFrontendEvalTransportSettings({
evalMode: mode,
requestedTransport: process.env.WMILL_FRONTEND_AI_EVAL_TRANSPORT,
});
const selectedCases = await loadSelectedCases(mode, caseIds);
const modeRunner = getModeRunner(
mode,
getFrontendEvalModel(model),
backendValidation,
transportSettings,
);
const runModel = formatRunModelLabel(mode, model);
const caseResults = await runSuite({
modeRunner,
cases: selectedCases,
runs,
runModel,
judgeModel: DEFAULT_JUDGE_MODEL,
concurrency: verbose ? 1 : undefined,
verbose,
onProgress: emitProgress
? (event) => emitFrontendBenchmarkProgress(event)
: undefined,
});
return buildRunResult({
mode,
runs,
runModel,
transport: transportSettings.transport,
judgeModel: DEFAULT_JUDGE_MODEL,
caseResults,
});
}
function getModeRunner(
mode: FrontendBenchmarkMode,
model: ReturnType<typeof getFrontendEvalModel>,
backendValidation: ReturnType<typeof resolveBackendValidationSettings>,
transportSettings: ReturnType<typeof resolveFrontendEvalTransportSettings>,
): ModeRunner<any, any, any> {
switch (mode) {
case "flow":
return createFlowModeRunner(model, backendValidation, transportSettings);
case "app":
return createAppModeRunner(model, transportSettings);
case "script":
return createScriptModeRunner(
model,
backendValidation,
transportSettings,
);
}
}
function parseMode(value: string | undefined): FrontendBenchmarkMode {
if (value === "flow" || value === "app" || value === "script") {
return value;
}
throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`);
}
function parseOptionalJsonStringArray(value: string | undefined): string[] {
if (!value) {
return [];
}
const parsed = JSON.parse(value) as unknown;
if (
!Array.isArray(parsed) ||
parsed.some((entry) => typeof entry !== "string")
) {
throw new Error(
"WMILL_FRONTEND_AI_EVAL_CASE_IDS must be a JSON string array",
);
}
return parsed;
}
function parsePositiveInteger(
value: string | undefined,
envName: string,
): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${envName} must be a positive integer`);
}
return parsed;
}
@@ -0,0 +1,180 @@
import { mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type {
BackendRunnable,
AppAIChatHelpers,
DataTableSchema,
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
import {
getAppTools,
prepareAppSystemMessage,
prepareAppUserMessage,
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { createAppFileHelpers } from "./fileHelpers";
import { runEval } from "../shared";
import type { AIProvider } from "$lib/gen/types.gen";
import type {
EvalCaseRuntimeAppAdditionalContext,
EvalCaseRuntimeAppContextSpec,
ModeRunContext,
} from "../../../../core/types";
import type { TokenUsage } from "../shared/types";
import type { AppFilesState } from "../../../../core/validators";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
import {
createAppBackendRunnableContextElement,
createAppDatatableContextElement,
createAppFrontendFileContextElement,
type ContextElement,
} from "../../../../../frontend/src/lib/components/copilot/chat/context";
export interface AppEvalResult {
success: boolean;
files: AppFilesState;
error?: string;
assistantMessageCount: number;
toolCallCount: number;
toolsUsed: string[];
tokenUsage: TokenUsage;
}
export interface AppEvalOptions {
initialFrontend?: Record<string, string>;
initialBackend?: AppFilesState["backend"];
initialDatatables?: AppFilesState["datatables"];
appContext?: EvalCaseRuntimeAppContextSpec;
model?: string;
maxIterations?: number;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
export async function runAppEval(
userPrompt: string,
apiKey: string,
options?: AppEvalOptions,
): Promise<AppEvalResult> {
const workspaceRoot =
options?.workspaceRoot ??
(await mkdtemp(join(tmpdir(), "wmill-frontend-app-benchmark-")));
const { helpers, getEvalState, cleanup } = await createAppFileHelpers(
options?.initialFrontend ?? {},
(options?.initialBackend ?? {}) as Record<string, BackendRunnable>,
options?.initialDatatables ?? [],
workspaceRoot,
);
try {
const systemMessage = prepareAppSystemMessage();
const tools = getAppTools() as ProductionTool<AppAIChatHelpers>[];
const model = options?.model ?? "claude-haiku-4-5-20251001";
const additionalContext = await buildAdditionalContext(
options?.appContext,
helpers,
);
const userMessage = prepareAppUserMessage(
userPrompt,
helpers.getSelectedContext(),
additionalContext,
);
const rawResult = await runEval({
userPrompt,
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput: getEvalState,
onAssistantMessageStart: options?.runContext?.onAssistantMessageStart,
onAssistantToken: options?.runContext?.onAssistantChunk,
onAssistantMessageEnd: options?.runContext?.onAssistantMessageEnd,
onToolCall: options?.runContext?.onToolCall,
options: {
maxIterations: options?.maxIterations,
model,
workspace: workspaceRoot,
provider: options?.provider,
transport: options?.transport,
backend: options?.backend,
proxyCaseId: options?.runContext?.caseId,
proxyAttempt: options?.runContext?.attempt,
},
});
return {
files: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
tokenUsage: rawResult.tokenUsage,
};
} finally {
await cleanup();
}
}
async function buildAdditionalContext(
appContext: EvalCaseRuntimeAppContextSpec | undefined,
helpers: AppAIChatHelpers,
): Promise<ContextElement[]> {
const entries = appContext?.additional ?? [];
if (entries.length === 0) {
return [];
}
const datatables = entries.some((entry) => entry.type === "datatable")
? await helpers.getDatatables()
: [];
return entries.map((entry) =>
buildAdditionalContextElement(entry, helpers, datatables),
);
}
function buildAdditionalContextElement(
entry: EvalCaseRuntimeAppAdditionalContext,
helpers: AppAIChatHelpers,
datatables: DataTableSchema[],
): ContextElement {
if (entry.type === "frontend") {
const content = helpers.getFrontendFile(entry.path);
if (content === undefined) {
throw new Error(`App eval @ frontend context not found: ${entry.path}`);
}
return createAppFrontendFileContextElement(entry.path, content);
}
if (entry.type === "backend") {
const runnable = helpers.getBackendRunnable(entry.key);
if (!runnable) {
throw new Error(`App eval @ backend context not found: ${entry.key}`);
}
return createAppBackendRunnableContextElement(entry.key, runnable);
}
const datatable = datatables.find(
(candidate) => candidate.datatable_name === entry.datatableName,
);
const columns = datatable?.schemas?.[entry.schema]?.[entry.table];
if (!columns) {
throw new Error(
`App eval @ datatable context not found: ${entry.datatableName}/${entry.schema}.${entry.table}`,
);
}
return createAppDatatableContextElement(
entry.datatableName,
entry.schema,
entry.table,
columns,
);
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "bun:test";
import { fileURLToPath } from "node:url";
import { loadAppFixture } from "./appFixtureLoader";
const RECIPE_BOOK_FIXTURE = fileURLToPath(
new URL("../../../../fixtures/frontend/app/initial/recipe_book", import.meta.url)
);
describe("loadAppFixture", () => {
it("loads datatables from app fixtures when present", async () => {
const fixture = await loadAppFixture(RECIPE_BOOK_FIXTURE);
expect(fixture.datatables).toEqual([
{
datatable_name: "main",
schemas: {
public: {
recipes: {
id: "int4",
name: "text",
ingredients: "text",
instructions: "text",
created_at: "timestamp=now()",
},
},
},
},
]);
});
});
@@ -0,0 +1,208 @@
import type {
BackendRunnable,
DataTableSchema,
InlineScript,
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
import type { AppFilesState } from "../../../../core/validators";
/**
* Backend runnable metadata stored in meta.json files.
*/
interface BackendMeta {
name: string;
language: "bun" | "python3";
}
/**
* Recursively reads all files in a directory and returns them as a record.
* File paths are relative to the base directory with a leading '/'.
*/
async function readFilesRecursively(
dir: string,
basePath: string = "",
): Promise<Record<string, string>> {
// @ts-ignore - Node.js fs/promises
const { readdir, readFile } = await import("fs/promises");
// @ts-ignore - Node.js path
const { join } = await import("path");
const result: Record<string, string> = {};
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const relativePath = basePath
? `${basePath}/${entry.name}`
: `/${entry.name}`;
if (entry.isDirectory()) {
const subFiles = await readFilesRecursively(fullPath, relativePath);
Object.assign(result, subFiles);
} else {
const content = await readFile(fullPath, "utf-8");
result[relativePath] = content;
}
}
return result;
}
/**
* Loads frontend files from a directory.
* All files are read recursively and paths become keys with leading '/'.
*/
async function loadFrontend(
frontendPath: string,
): Promise<Record<string, string>> {
// @ts-ignore - Node.js fs/promises
const { access } = await import("fs/promises");
try {
await access(frontendPath);
} catch {
// Directory doesn't exist, return empty
return {};
}
return readFilesRecursively(frontendPath);
}
/**
* Loads backend runnables from a directory.
* Each subdirectory is a runnable with:
* - main.ts or main.py: The code content
* - meta.json: Metadata { name, language }
*/
async function loadBackend(
backendPath: string,
): Promise<Record<string, BackendRunnable>> {
// @ts-ignore - Node.js fs/promises
const { readdir, readFile, access } = await import("fs/promises");
// @ts-ignore - Node.js path
const { join } = await import("path");
try {
await access(backendPath);
} catch {
// Directory doesn't exist, return empty
return {};
}
const result: Record<string, BackendRunnable> = {};
const entries = await readdir(backendPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const runnableKey = entry.name;
const runnablePath = join(backendPath, entry.name);
// Read meta.json
const metaPath = join(runnablePath, "meta.json");
let meta: BackendMeta;
try {
const metaContent = await readFile(metaPath, "utf-8");
meta = JSON.parse(metaContent);
} catch {
console.warn(
`Missing or invalid meta.json for runnable '${runnableKey}', skipping`,
);
continue;
}
// Find and read the main file (main.ts or main.py)
const runnableFiles = await readdir(runnablePath);
const mainFile = runnableFiles.find(
(f) => f === "main.ts" || f === "main.py",
);
if (!mainFile) {
console.warn(
`No main.ts or main.py found for runnable '${runnableKey}', skipping`,
);
continue;
}
const content = await readFile(join(runnablePath, mainFile), "utf-8");
const inlineScript: InlineScript = {
language: meta.language,
content,
};
result[runnableKey] = {
name: meta.name,
type: "inline",
inlineScript,
};
}
return result;
}
async function loadDatatables(fixturePath: string): Promise<DataTableSchema[]> {
// @ts-ignore - Node.js fs/promises
const { readFile } = await import("fs/promises");
// @ts-ignore - Node.js path
const { join } = await import("path");
try {
const content = await readFile(
join(fixturePath, "datatables.json"),
"utf-8",
);
const parsed = JSON.parse(content);
return Array.isArray(parsed) ? (parsed as DataTableSchema[]) : [];
} catch {
return [];
}
}
/**
* Loads an app fixture from a directory structure.
*
* Expected structure:
* ```
* fixturePath/
* ├── frontend/
* │ └── index.tsx # → frontend["/index.tsx"]
* │ └── components/
* │ └── Button.tsx # → frontend["/components/Button.tsx"]
* └── backend/
* └── incrementCounter/
* ├── main.ts # The code content
* └── meta.json # { "name": "...", "language": "bun" }
* ```
*
* @param fixturePath - Path to the fixture directory
* @returns AppFiles object with frontend and backend
*/
export async function loadAppFixture(
fixturePath: string,
): Promise<AppFilesState> {
// @ts-ignore - Node.js path
const { join } = await import("path");
const frontend = await loadFrontend(join(fixturePath, "frontend"));
const backend = await loadBackend(join(fixturePath, "backend"));
const datatables = await loadDatatables(fixturePath);
return { frontend, backend, datatables };
}
/**
* Loads an app fixture and returns the separate frontend and backend objects.
* Convenience function for use with runAppEval options.
*/
export async function loadAppFixtureForEval(fixturePath: string): Promise<{
initialFrontend: Record<string, string>;
initialBackend: AppFilesState["backend"];
initialDatatables: DataTableSchema[];
}> {
const { frontend, backend, datatables } = await loadAppFixture(fixturePath);
return {
initialFrontend: frontend,
initialBackend: backend,
initialDatatables: datatables,
};
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "bun:test";
import { createAppFileHelpers } from "./fileHelpers";
describe("createAppFileHelpers", () => {
it("exposes generated wmill typings and returns real lint diagnostics", async () => {
const { helpers, cleanup } = await createAppFileHelpers(
{
"/index.tsx":
"import { backend } from 'wmill'\nexport default function App() { void backend.listRecipes(); return <div /> }\n",
},
{
listRecipes: {
name: "List recipes",
type: "inline",
inlineScript: {
language: "bun",
content: "export async function main() { return [] }\n",
},
},
}
);
try {
expect(helpers.listFrontendFiles()).toContain("/wmill.d.ts");
expect(helpers.getFrontendFile("/wmill.d.ts")).toContain("listRecipes");
const lintResult = helpers.setFrontendFile(
"/index.tsx",
"import { backend } from 'wmill'\nexport default function App() { void backend.deleteRecipe({ id: 1 }); return <div /> }\n"
);
expect(lintResult.errorCount).toBeGreaterThan(0);
expect(lintResult.errors.frontend["/index.tsx"]?.join("\n")).toContain(
"Property 'deleteRecipe' does not exist"
);
} finally {
await cleanup();
}
});
});
@@ -0,0 +1,296 @@
import { mkdir, rm, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
import type {
AppAIChatHelpers,
AppFiles,
BackendRunnable,
DataTableSchema,
LintResult,
SelectedContext
} from '../../../../../frontend/src/lib/components/copilot/chat/app/core'
import { buildAppWmillTypes, collectAppDiagnostics } from '../../../../core/appDiagnostics'
async function writeFrontendFile(
workspaceRoot: string | undefined,
path: string,
content: string
): Promise<void> {
if (!workspaceRoot) {
return
}
const relativePath = path.startsWith('/') ? path.slice(1) : path
const fullPath = join(workspaceRoot, 'frontend', relativePath)
await mkdir(dirname(fullPath), { recursive: true })
await writeFile(fullPath, content, 'utf8')
}
async function removeFrontendFile(workspaceRoot: string | undefined, path: string): Promise<void> {
if (!workspaceRoot) {
return
}
const relativePath = path.startsWith('/') ? path.slice(1) : path
await rm(join(workspaceRoot, 'frontend', relativePath), { force: true })
}
async function writeBackendRunnable(
workspaceRoot: string | undefined,
key: string,
runnable: BackendRunnable
): Promise<void> {
if (!workspaceRoot) {
return
}
const runnableDir = join(workspaceRoot, 'backend', key)
await mkdir(runnableDir, { recursive: true })
const meta: { name: string; language?: string; type?: string; path?: string } = {
name: runnable.name
}
if (runnable.type === 'inline' && runnable.inlineScript) {
meta.language = runnable.inlineScript.language
const extension = runnable.inlineScript.language === 'python3' ? 'py' : 'ts'
await writeFile(
join(runnableDir, `main.${extension}`),
runnable.inlineScript.content,
'utf8'
)
} else {
meta.type = runnable.type
if (runnable.path) {
meta.path = runnable.path
}
}
await writeFile(join(runnableDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8')
}
async function removeBackendRunnable(workspaceRoot: string | undefined, key: string): Promise<void> {
if (!workspaceRoot) {
return
}
await rm(join(workspaceRoot, 'backend', key), { recursive: true, force: true })
}
async function persistDatatables(
workspaceRoot: string | undefined,
datatables: DataTableSchema[]
): Promise<void> {
if (!workspaceRoot) {
return
}
await writeFile(
join(workspaceRoot, 'datatables.json'),
JSON.stringify(datatables, null, 2) + '\n',
'utf8'
)
}
export async function createAppFileHelpers(
initialFrontend: Record<string, string> = {},
initialBackend: Record<string, BackendRunnable> = {},
initialDatatables: DataTableSchema[] = [],
workspaceRoot?: string
): Promise<{
helpers: AppAIChatHelpers
getFiles: () => AppFiles
getEvalState: () => {
frontend: Record<string, string>
backend: Record<string, BackendRunnable>
datatables: DataTableSchema[]
}
getFrontend: () => Record<string, string>
getBackend: () => Record<string, BackendRunnable>
getDatatables: () => DataTableSchema[]
cleanup: () => Promise<void>
workspaceDir: string | null
}> {
let frontend = { ...initialFrontend }
let backend = { ...initialBackend }
let snapshotId = 0
const snapshots = new Map<
number,
{
frontend: Record<string, string>
backend: Record<string, BackendRunnable>
datatables: DataTableSchema[]
}
>()
const datatables: DataTableSchema[] = structuredClone(initialDatatables)
function lint(): LintResult {
return collectAppDiagnostics({
frontend,
backend
}).lintResult
}
function getGeneratedWmillTypes(): string {
return buildAppWmillTypes(backend)
}
for (const [path, content] of Object.entries(frontend)) {
await writeFrontendFile(workspaceRoot, path, content)
}
for (const [key, runnable] of Object.entries(backend)) {
await writeBackendRunnable(workspaceRoot, key, runnable)
}
await persistDatatables(workspaceRoot, datatables)
const helpers: AppAIChatHelpers = {
listFrontendFiles: () => [
...Object.keys(frontend).filter((path) => path !== '/wmill.d.ts'),
'/wmill.d.ts'
],
getFrontendFile: (path: string) => {
if (path === '/wmill.d.ts') {
return getGeneratedWmillTypes()
}
return frontend[path]
},
getFrontendFiles: () => ({
...Object.fromEntries(
Object.entries(frontend).filter(([path]) => path !== '/wmill.d.ts')
),
'/wmill.d.ts': getGeneratedWmillTypes()
}),
setFrontendFile: (path: string, content: string) => {
if (path === '/wmill.d.ts') {
return lint()
}
frontend[path] = content
void writeFrontendFile(workspaceRoot, path, content)
return lint()
},
deleteFrontendFile: (path: string) => {
if (path === '/wmill.d.ts') {
return
}
delete frontend[path]
void removeFrontendFile(workspaceRoot, path)
},
listBackendRunnables: () =>
Object.entries(backend).map(([key, runnable]) => ({
key,
name: runnable.name
})),
getBackendRunnable: (key: string) => backend[key],
getBackendRunnables: () => ({ ...backend }),
setBackendRunnable: async (key: string, runnable: BackendRunnable) => {
backend[key] = runnable
await writeBackendRunnable(workspaceRoot, key, runnable)
return lint()
},
deleteBackendRunnable: (key: string) => {
delete backend[key]
void removeBackendRunnable(workspaceRoot, key)
},
getFiles: (): AppFiles => ({
frontend: { ...frontend },
backend: { ...backend }
}),
getSelectedContext: (): SelectedContext => ({}),
snapshot: () => {
const id = ++snapshotId
snapshots.set(id, {
frontend: { ...frontend },
backend: { ...backend },
datatables: structuredClone(datatables)
})
return id
},
revertToSnapshot: (id: number) => {
const snapshot = snapshots.get(id)
if (!snapshot) {
return
}
frontend = { ...snapshot.frontend }
backend = { ...snapshot.backend }
datatables.splice(0, datatables.length, ...structuredClone(snapshot.datatables))
void syncWorkspace()
},
lint,
getDatatables: async () => structuredClone(datatables),
getAvailableDatatableNames: () => datatables.map((datatable) => datatable.datatable_name),
execDatatableSql: async (
datatableName: string,
sql: string,
newTable?: { schema: string; name: string }
) => {
if (newTable) {
datatables.push({
datatable_name: datatableName,
schemas: {
[newTable.schema]: {
[newTable.name]: {}
}
}
})
await persistDatatables(workspaceRoot, datatables)
}
return {
success: true,
result: [
{
datatableName,
sql
}
]
}
},
addTableToWhitelist: (datatableName: string, schemaName: string, tableName: string) => {
const existing = datatables.find((entry) => entry.datatable_name === datatableName)
if (existing) {
existing.schemas[schemaName] ??= {}
existing.schemas[schemaName][tableName] ??= {}
} else {
datatables.push({
datatable_name: datatableName,
schemas: {
[schemaName]: {
[tableName]: {}
}
}
})
}
void persistDatatables(workspaceRoot, datatables)
}
}
async function syncWorkspace(): Promise<void> {
if (!workspaceRoot) {
return
}
await rm(join(workspaceRoot, 'frontend'), { recursive: true, force: true })
await rm(join(workspaceRoot, 'backend'), { recursive: true, force: true })
for (const [path, content] of Object.entries(frontend)) {
await writeFrontendFile(workspaceRoot, path, content)
}
for (const [key, runnable] of Object.entries(backend)) {
await writeBackendRunnable(workspaceRoot, key, runnable)
}
await persistDatatables(workspaceRoot, datatables)
}
return {
helpers,
getFiles: () => ({
frontend: { ...frontend },
backend: { ...backend }
}),
getEvalState: () => ({
frontend: { ...frontend },
backend: { ...backend },
datatables: structuredClone(datatables)
}),
getFrontend: () => ({ ...frontend }),
getBackend: () => ({ ...backend }),
getDatatables: () => structuredClone(datatables),
cleanup: async () => {
if (workspaceRoot) {
await rm(workspaceRoot, { recursive: true, force: true })
}
},
workspaceDir: workspaceRoot ?? null
}
}
@@ -0,0 +1,175 @@
import { mkdir, rm, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
import type { FlowModule, InputTransform } from '../../../../../frontend/src/lib/gen'
import type { ExtendedOpenFlow } from '../../../../../frontend/src/lib/components/flows/types'
import type { FlowAIChatHelpers } from '../../../../../frontend/src/lib/components/copilot/chat/flow/core'
import type { ScriptLintResult } from '../../../../../frontend/src/lib/components/copilot/chat/shared'
import type { WorkspaceMutationTarget } from '../../../../../frontend/src/lib/components/copilot/chat/workspaceTools'
import {
createInlineScriptSession
} from '../../../../../frontend/src/lib/components/copilot/chat/flow/inlineScriptsUtils'
import {
applyFlowJsonUpdate,
updateRawScriptModuleContent
} from '../../../../../frontend/src/lib/components/copilot/chat/flow/helperUtils'
import {
registerBenchmarkWorkspace,
registerBenchmarkWorkspaceRunnables,
unregisterBenchmarkWorkspaceRunnables,
createBenchmarkCompletedJob,
type BenchmarkWorkspaceFlow,
type BenchmarkWorkspaceScript
} from '../../mockBackend'
const EMPTY_SCRIPT_LINT_RESULT: ScriptLintResult = {
errorCount: 0,
warningCount: 0,
errors: [],
warnings: []
}
export interface FlowWorkspaceFixtures {
scripts?: BenchmarkWorkspaceScript[]
flows?: BenchmarkWorkspaceFlow[]
}
export async function createFlowFileHelpers(
initialModules: FlowModule[] = [],
initialSchema?: Record<string, any>,
initialPreprocessorModule?: FlowModule,
initialFailureModule?: FlowModule,
workspaceRoot?: string,
workspaceFixtures?: FlowWorkspaceFixtures,
currentFlowPath?: string
): Promise<{
helpers: FlowAIChatHelpers
getFlow: () => ExtendedOpenFlow
getModules: () => FlowModule[]
cleanup: () => Promise<void>
workspaceDir: string | null
}> {
let flow: ExtendedOpenFlow = {
value: {
modules: structuredClone(initialModules),
preprocessor_module: structuredClone(initialPreprocessorModule),
failure_module: structuredClone(initialFailureModule)
},
summary: '',
schema: initialSchema ?? {
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties: {},
required: [],
type: 'object'
}
}
const inlineScriptSession = createInlineScriptSession()
const flowFilePath = workspaceRoot ? join(workspaceRoot, 'flow.json') : null
async function persistFlow(): Promise<void> {
if (!flowFilePath) {
return
}
await mkdir(dirname(flowFilePath), { recursive: true })
await writeFile(flowFilePath, JSON.stringify(flow, null, 2) + '\n', 'utf8')
}
await persistFlow()
if (workspaceRoot) {
registerBenchmarkWorkspace(workspaceRoot)
if (workspaceFixtures) {
registerBenchmarkWorkspaceRunnables(workspaceRoot, workspaceFixtures)
}
}
const setFlowJson: FlowAIChatHelpers['setFlowJson'] = async ({
modules,
schema,
preprocessorModule,
failureModule
}) => {
const result = applyFlowJsonUpdate(flow, inlineScriptSession, {
modules,
schema,
preprocessorModule,
failureModule
})
await persistFlow()
return result
}
const helpers: FlowAIChatHelpers & {
getWorkspaceMutationTarget: () => WorkspaceMutationTarget
} = {
getFlowAndSelectedId: () => ({ flow, selectedId: '' }),
getRootModules: () => flow.value.modules,
inlineScriptSession,
getWorkspaceMutationTarget: () => ({
kind: 'flow',
path: currentFlowPath,
deployed: Boolean(currentFlowPath)
}),
setSnapshot: () => {},
revertToSnapshot: () => {},
setCode: async (id: string, code: string) => {
updateRawScriptModuleContent(flow, id, code)
inlineScriptSession.set(id, code)
await persistFlow()
},
setFlowJson,
getFlowInputsSchema: async () => flow.schema ?? {},
updateExprsToSet: (_id: string, _inputTransforms: Record<string, InputTransform>) => {},
acceptAllModuleActions: () => {},
rejectAllModuleActions: () => {},
hasPendingChanges: () => false,
selectStep: (_id: string) => {},
testFlow: async (args?: Record<string, any>) => {
if (workspaceRoot) {
const runPath = join(workspaceRoot, 'test-run.json')
await writeFile(
runPath,
JSON.stringify(
{
requestedArgs: args ?? {},
modules: flow.value.modules.map((module) => module.id),
preprocessor_module: flow.value.preprocessor_module?.id ?? null,
failure_module: flow.value.failure_module?.id ?? null
},
null,
2
) + '\n',
'utf8'
)
}
return createBenchmarkCompletedJob({
workspace: workspaceRoot ?? 'benchmark',
jobKind: 'flowpreview',
result: {
requestedArgs: args ?? {},
modules: flow.value.modules.map((module) => module.id),
preprocessor_module: flow.value.preprocessor_module?.id ?? null,
failure_module: flow.value.failure_module?.id ?? null,
mocked: true
},
logs: 'Mock benchmark flow test run completed successfully.'
})
},
getLintErrors: async () => EMPTY_SCRIPT_LINT_RESULT
}
return {
helpers,
getFlow: () => flow,
getModules: () => flow.value.modules,
cleanup: async () => {
if (workspaceRoot) {
unregisterBenchmarkWorkspaceRunnables(workspaceRoot)
}
if (workspaceRoot) {
await rm(workspaceRoot, { recursive: true, force: true })
}
},
workspaceDir: workspaceRoot ?? null
}
}
@@ -0,0 +1,123 @@
import { mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type { FlowModule } from "$lib/gen";
import type { AIProvider } from "$lib/gen/types.gen";
import type { ExtendedOpenFlow } from "$lib/components/flows/types";
import {
flowTools,
prepareFlowSystemMessage,
prepareFlowUserMessage,
type FlowAIChatHelpers,
} from "../../../../../frontend/src/lib/components/copilot/chat/flow/core";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import {
createFlowFileHelpers,
type FlowWorkspaceFixtures,
} from "./fileHelpers";
import { runEval } from "../shared";
import type { ModeRunContext } from "../../../../core/types";
import type { TokenUsage, ToolCallDetail } from "../shared/types";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
export interface FlowFixture {
path?: string;
value?: {
modules?: FlowModule[];
preprocessor_module?: FlowModule;
failure_module?: FlowModule;
};
schema?: Record<string, unknown>;
}
export interface FlowEvalResult {
success: boolean;
flow: ExtendedOpenFlow;
error?: string;
assistantMessageCount: number;
toolCallCount: number;
toolsUsed: string[];
toolCallDetails: ToolCallDetail[];
tokenUsage: TokenUsage;
}
export interface FlowEvalOptions {
initialFlow?: FlowFixture;
workspaceFixtures?: FlowWorkspaceFixtures;
model?: string;
maxIterations?: number;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
export async function runFlowEval(
userPrompt: string,
apiKey: string,
options?: FlowEvalOptions,
): Promise<FlowEvalResult> {
const workspaceRoot =
options?.workspaceRoot ??
(await mkdtemp(join(tmpdir(), "wmill-frontend-flow-benchmark-")));
const { helpers, getFlow, cleanup } = await createFlowFileHelpers(
options?.initialFlow?.value?.modules ?? [],
options?.initialFlow?.schema,
options?.initialFlow?.value?.preprocessor_module,
options?.initialFlow?.value?.failure_module,
workspaceRoot,
options?.workspaceFixtures,
options?.initialFlow?.path,
);
try {
const systemMessage = prepareFlowSystemMessage();
const tools = flowTools as ProductionTool<FlowAIChatHelpers>[];
const model = options?.model ?? "claude-haiku-4-5-20251001";
const userMessage = prepareFlowUserMessage(
userPrompt,
helpers.getFlowAndSelectedId(),
[],
helpers.inlineScriptSession,
);
const rawResult = await runEval({
userPrompt,
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput: getFlow,
onAssistantMessageStart: options?.runContext?.onAssistantMessageStart,
onAssistantToken: options?.runContext?.onAssistantChunk,
onAssistantMessageEnd: options?.runContext?.onAssistantMessageEnd,
onToolCall: options?.runContext?.onToolCall,
options: {
maxIterations: options?.maxIterations,
model,
workspace: workspaceRoot,
provider: options?.provider,
transport: options?.transport,
backend: options?.backend,
proxyCaseId: options?.runContext?.caseId,
proxyAttempt: options?.runContext?.attempt,
},
});
return {
flow: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
toolCallDetails: rawResult.toolCallDetails,
tokenUsage: rawResult.tokenUsage,
};
} finally {
await cleanup();
}
}
@@ -0,0 +1,92 @@
import { mkdir, rm, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
import type { ScriptLang } from '../../../../../frontend/src/lib/gen/types.gen'
import type { ScriptChatHelpers } from '../../../../../frontend/src/lib/components/copilot/chat/script/core'
import type { WorkspaceMutationTarget } from '../../../../../frontend/src/lib/components/copilot/chat/workspaceTools'
import { buildScriptLintResult } from './preview'
import { registerBenchmarkWorkspace, unregisterBenchmarkWorkspace } from '../../mockBackend'
export interface ScriptEvalState {
code: string
lang: ScriptLang | 'bunnative'
path: string
args: Record<string, any>
}
function toRunnablePath(filePath: string): string {
return filePath.replace(/\.[^/.]+$/, '')
}
export async function createScriptFileHelpers(
initialScript: ScriptEvalState,
workspaceRoot?: string
): Promise<{
helpers: ScriptChatHelpers
getScript: () => ScriptEvalState
cleanup: () => Promise<void>
workspaceDir: string | null
}> {
let script = structuredClone(initialScript)
const scriptFilePath = workspaceRoot ? join(workspaceRoot, script.path) : null
async function persistScript(): Promise<void> {
if (!scriptFilePath) {
return
}
await mkdir(dirname(scriptFilePath), { recursive: true })
await writeFile(scriptFilePath, script.code, 'utf8')
}
await persistScript()
if (workspaceRoot) {
registerBenchmarkWorkspace(workspaceRoot)
}
const applyCode: NonNullable<ScriptChatHelpers['applyCode']> = async (
code,
opts
) => {
if (opts?.mode === 'revert') {
return
}
script = {
...script,
code
}
await persistScript()
}
const getLintErrors: NonNullable<ScriptChatHelpers['getLintErrors']> = () =>
buildScriptLintResult(script.code, script.lang)
const helpers: ScriptChatHelpers & {
getWorkspaceMutationTarget: () => WorkspaceMutationTarget
} = {
getScriptOptions: () => ({
code: script.code,
lang: script.lang,
path: script.path,
args: structuredClone(script.args)
}),
getWorkspaceMutationTarget: () => ({
kind: 'script',
path: script.path ? toRunnablePath(script.path) : undefined,
deployed: Boolean(script.path)
}),
applyCode,
getLintErrors
}
return {
helpers,
getScript: () => structuredClone(script),
cleanup: async () => {
if (workspaceRoot) {
unregisterBenchmarkWorkspace(workspaceRoot)
await rm(workspaceRoot, { recursive: true, force: true })
}
},
workspaceDir: workspaceRoot ?? null
}
}
@@ -0,0 +1,96 @@
import ts from 'typescript'
import type { ScriptLang } from '../../../../../frontend/src/lib/gen/types.gen'
import type { ScriptLintResult } from '../../../../../frontend/src/lib/components/copilot/chat/shared'
export type ScriptPreviewLanguage = ScriptLang | 'bunnative'
const TS_LIKE_LANGUAGES = new Set<ScriptPreviewLanguage>(['bun', 'deno', 'nativets', 'bunnative'])
const JS_LIKE_LANGUAGES = new Set<ScriptPreviewLanguage>(['bun', 'deno', 'nativets', 'bunnative'])
function hasSupportedEntrypoint(code: string): boolean {
return (
/export\s+(async\s+)?function\s+main\s*\(/.test(code) ||
/export\s+(async\s+)?function\s+preprocessor\s*\(/.test(code)
)
}
function compilerOptionsForLanguage(lang: ScriptPreviewLanguage): ts.CompilerOptions | null {
if (!TS_LIKE_LANGUAGES.has(lang)) {
return null
}
return {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
noEmit: true,
allowJs: true,
checkJs: false,
strict: false,
skipLibCheck: true
}
}
function getLineAndColumn(sourceText: string, start: number): { line: number; column: number } {
const prefix = sourceText.slice(0, Math.max(0, start))
const line = prefix.split('\n').length
const lastNewline = prefix.lastIndexOf('\n')
const column = lastNewline === -1 ? prefix.length + 1 : prefix.length - lastNewline
return { line, column }
}
export function buildScriptLintResult(
code: string,
lang: ScriptPreviewLanguage
): ScriptLintResult {
const diagnostics: ScriptLintResult['errors'] = []
const compilerOptions = compilerOptionsForLanguage(lang)
if (compilerOptions) {
const sourceFile = ts.createSourceFile(
'script.ts',
code,
ts.ScriptTarget.ES2022,
true,
JS_LIKE_LANGUAGES.has(lang) ? ts.ScriptKind.TS : ts.ScriptKind.JS
)
const output = ts.transpileModule(code, {
compilerOptions,
fileName: sourceFile.fileName,
reportDiagnostics: true
})
for (const diagnostic of output.diagnostics ?? []) {
const start = diagnostic.start ?? 0
const length = diagnostic.length ?? 1
const { line, column } = getLineAndColumn(code, start)
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
diagnostics.push({
startLineNumber: line,
startColumn: column,
endLineNumber: line,
endColumn: column + Math.max(1, length),
message,
severity: 8
} as ScriptLintResult['errors'][number])
}
}
if (!hasSupportedEntrypoint(code)) {
diagnostics.push({
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: 1,
message: 'Script must export a main or preprocessor function.',
severity: 8
} as ScriptLintResult['errors'][number])
}
return {
errorCount: diagnostics.length,
warningCount: 0,
errors: diagnostics,
warnings: []
}
}
@@ -0,0 +1,121 @@
import { mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type { AIProvider, AIProviderModel } from "$lib/gen/types.gen";
import type { ContextElement } from "../../../../../frontend/src/lib/components/copilot/chat/context";
import {
prepareScriptSystemMessage,
prepareScriptTools,
prepareScriptUserMessage,
type ScriptChatHelpers,
} from "../../../../../frontend/src/lib/components/copilot/chat/script/core";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { createScriptFileHelpers, type ScriptEvalState } from "./fileHelpers";
import { runEval } from "../shared";
import type { ModeRunContext } from "../../../../core/types";
import type { TokenUsage, ToolCallDetail } from "../shared/types";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
export interface ScriptEvalResult {
success: boolean;
script: ScriptEvalState;
error?: string;
assistantMessageCount: number;
toolCallCount: number;
toolsUsed: string[];
toolCallDetails: ToolCallDetail[];
tokenUsage: TokenUsage;
}
export interface ScriptEvalOptions {
initialScript: ScriptEvalState;
model?: string;
maxIterations?: number;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
function resolveModelProvider(
model: string,
provider?: AIProvider,
): AIProviderModel {
if (provider) {
return { provider, model };
}
if (model.startsWith("claude")) {
return { provider: "anthropic", model };
}
return { provider: "openai", model };
}
export async function runScriptEval(
userPrompt: string,
apiKey: string,
options: ScriptEvalOptions,
): Promise<ScriptEvalResult> {
const workspaceRoot =
options.workspaceRoot ??
(await mkdtemp(join(tmpdir(), "wmill-frontend-script-benchmark-")));
const { helpers, getScript, cleanup } = await createScriptFileHelpers(
options.initialScript,
workspaceRoot,
);
try {
const model = options.model ?? "claude-haiku-4-5-20251001";
const modelProvider = resolveModelProvider(model, options.provider);
const selectedContext: ContextElement[] = [];
const systemMessage = prepareScriptSystemMessage(
modelProvider,
options.initialScript.lang,
{},
);
const tools = prepareScriptTools(
modelProvider,
options.initialScript.lang,
selectedContext,
) as ProductionTool<ScriptChatHelpers>[];
const userMessage = prepareScriptUserMessage(userPrompt, selectedContext);
const rawResult = await runEval({
userPrompt,
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput: getScript,
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
onAssistantToken: options.runContext?.onAssistantChunk,
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
onToolCall: options.runContext?.onToolCall,
options: {
maxIterations: options.maxIterations,
model,
workspace: workspaceRoot,
provider: modelProvider.provider,
transport: options.transport,
backend: options.backend,
proxyCaseId: options.runContext?.caseId,
proxyAttempt: options.runContext?.attempt,
},
});
return {
script: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
toolCallDetails: rawResult.toolCallDetails,
tokenUsage: rawResult.tokenUsage,
};
} finally {
await cleanup();
}
}
@@ -0,0 +1,258 @@
import type {
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam,
} from "openai/resources/chat/completions.mjs";
import type { AIProvider } from "$lib/gen/types.gen";
import type { ToolCallDetail, EvalRunnerOptions, RawEvalResult } from "./types";
import {
runChatLoop,
type ChatClients,
} from "../../../../../frontend/src/lib/components/copilot/chat/chatLoop";
import type {
Tool as ProductionTool,
ToolCallbacks,
} from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import {
buildProxyResourcePath,
createEvalClients,
type FrontendEvalProvider,
resolveEvalModelProvider,
} from "./providerConfig";
import { WindmillBackendClient } from "../../windmillBackend";
/**
* Parameters for running a base evaluation.
*/
export interface RunEvalParams<THelpers, TOutput> {
/** The user's prompt/instruction */
userPrompt: string;
/** System message for the LLM */
systemMessage: ChatCompletionSystemMessageParam;
/** User message for the LLM */
userMessage: ChatCompletionMessageParam;
/** Tool definitions for the LLM API (unused — derived from tools) */
toolDefs?: unknown;
/** Full tool implementations for execution */
tools: ProductionTool<THelpers>[];
/** Domain-specific helpers for tool execution */
helpers: THelpers;
/** API key for the provider */
apiKey: string;
/** Function to get the current output state */
getOutput: () => TOutput;
/** Optional configuration */
options?: EvalRunnerOptions;
onAssistantMessageStart?: () => void;
onAssistantToken?: (token: string) => void;
onAssistantMessageEnd?: () => void;
onToolCall?: (input: { toolName: string; argumentsText: string }) => void;
}
/**
* Runs a generic evaluation using the shared chat loop (same code path as production).
* Uses streaming via real provider SDKs instead of OpenRouter non-streaming.
*/
export async function runEval<THelpers, TOutput>(
params: RunEvalParams<THelpers, TOutput>,
): Promise<RawEvalResult<TOutput>> {
const {
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput,
options,
onAssistantMessageStart,
onAssistantToken,
onAssistantMessageEnd,
onToolCall,
} = params;
let shouldEmitMessageStart = true;
const model = options?.model ?? "gpt-4o";
const maxIterations = options?.maxIterations ?? 20;
const workspace = options?.workspace ?? "test-workspace";
const provider = toFrontendEvalProvider(options?.provider);
const modelProvider = resolveEvalModelProvider(model, provider);
const messages: ChatCompletionMessageParam[] = [userMessage];
let toolCallsCount = 0;
const toolsCalled: string[] = [];
const toolCallDetails: ToolCallDetail[] = [];
// Wrap tools to intercept fn calls for tracking.
// Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type
// but the actual callbacks passed at runtime will satisfy both interfaces.
const wrappedTools = tools.map((tool) => ({
...tool,
fn: async (p: any) => {
toolCallsCount++;
toolsCalled.push(tool.def.function.name);
let argumentsText = "";
try {
const args = typeof p.args === "string" ? JSON.parse(p.args) : p.args;
toolCallDetails.push({ name: tool.def.function.name, arguments: args });
argumentsText = JSON.stringify(args);
} catch {
toolCallDetails.push({
name: tool.def.function.name,
arguments: p.args,
});
argumentsText =
typeof p.args === "string" ? p.args : JSON.stringify(p.args);
}
onToolCall?.({
toolName: tool.def.function.name,
argumentsText,
});
return tool.fn(p);
},
}));
// No-op callbacks for eval
const callbacks: ToolCallbacks & {
onNewToken: (token: string) => void;
onMessageEnd: () => void;
} = {
setToolStatus: () => {},
removeToolStatus: () => {},
onNewToken: (token: string) => {
if (shouldEmitMessageStart) {
onAssistantMessageStart?.();
shouldEmitMessageStart = false;
}
onAssistantToken?.(token);
},
onMessageEnd: () => {
if (!shouldEmitMessageStart) {
onAssistantMessageEnd?.();
}
shouldEmitMessageStart = true;
},
};
const abortController = new AbortController();
const executeChatLoop = async (clients: ChatClients) => {
try {
const result = await runChatLoop({
messages,
systemMessage,
tools: wrappedTools,
helpers,
abortController,
callbacks,
modelProvider,
clients,
workspace,
maxIterations,
skipResponsesApi: modelProvider.provider !== "openai",
});
if (result.hitMaxIterations) {
return {
success: false,
output: getOutput(),
error: `Reached max turns (${maxIterations})`,
tokenUsage: result.tokenUsage,
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations: Math.max(
1,
result.addedMessages.filter((m) => m.role === "assistant").length,
),
messages,
};
}
return {
success: true,
output: getOutput(),
tokenUsage: result.tokenUsage,
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations: Math.max(
1,
result.addedMessages.filter((m) => m.role === "assistant").length,
),
messages,
};
} catch (err) {
let errorMessage: string;
if (err instanceof Error) {
errorMessage = err.stack ?? err.message;
} else {
errorMessage = String(err);
}
return {
success: false,
output: getOutput(),
error: errorMessage,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations: 0,
messages,
};
}
};
if (options?.transport === "proxy") {
const backendSettings = options.backend;
if (!backendSettings) {
throw new Error("Missing backend settings for proxy transport");
}
const backendClient = new WindmillBackendClient(backendSettings);
return await backendClient.withWorkspace(
options.proxyCaseId ?? "eval",
options.proxyAttempt ?? 1,
async (proxyWorkspaceId) => {
const resourcePath = buildProxyResourcePath(modelProvider.provider);
await backendClient.upsertResource({
workspaceId: proxyWorkspaceId,
path: resourcePath,
resourceType: modelProvider.provider,
value: { api_key: apiKey },
});
const token = await backendClient.getToken();
const clients = createEvalClients({
provider: modelProvider.provider,
apiKey,
transport: "proxy",
proxy: {
baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`,
bearerToken: token,
resourcePath,
},
}) as unknown as ChatClients;
return await executeChatLoop(clients);
},
);
}
const clients = createEvalClients({
provider: modelProvider.provider,
apiKey,
}) as unknown as ChatClients;
return await executeChatLoop(clients);
}
function toFrontendEvalProvider(
provider?: AIProvider,
): FrontendEvalProvider | undefined {
if (
provider === "anthropic" ||
provider === "openai" ||
provider === "googleai"
) {
return provider;
}
return undefined;
}
@@ -0,0 +1,3 @@
export type { TokenUsage, ToolCallDetail, EvalRunnerOptions, RawEvalResult } from './types'
export type { RunEvalParams } from './baseEvalRunner'
export { runEval } from './baseEvalRunner'
@@ -0,0 +1,62 @@
import { describe, expect, it } from "bun:test";
import {
buildProxyHeaders,
buildProxyResourcePath,
buildOpenAICompatibleClientOptions,
resolveEvalModelProvider,
} from "./providerConfig";
describe("buildOpenAICompatibleClientOptions", () => {
it("adds Gemini's OpenAI-compatible base URL and client header", () => {
const options = buildOpenAICompatibleClientOptions(
"googleai",
"gemini-test-key",
);
expect(options).toMatchObject({
apiKey: "gemini-test-key",
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",
defaultHeaders: {
"x-goog-api-client": "windmill-ai-evals/1.0",
},
});
});
it("keeps the default OpenAI-compatible config for OpenAI", () => {
expect(
buildOpenAICompatibleClientOptions("openai", "openai-test-key"),
).toEqual({
apiKey: "openai-test-key",
});
});
});
describe("proxy helpers", () => {
it("builds provider-scoped proxy resource paths", () => {
expect(buildProxyResourcePath("googleai")).toBe("f/evals/ai/googleai");
expect(buildProxyResourcePath("anthropic")).toBe("f/evals/ai/anthropic");
});
it("adds auth and resource headers for workspace proxy requests", () => {
expect(buildProxyHeaders("token-123", "f/evals/ai/googleai")).toEqual({
Authorization: "Bearer token-123",
"X-Resource-Path": "f/evals/ai/googleai",
});
});
});
describe("resolveEvalModelProvider", () => {
it("infers googleai from Gemini model ids", () => {
expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({
provider: "googleai",
model: "gemini-2.5-flash",
});
});
it("preserves an explicit provider", () => {
expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({
provider: "googleai",
model: "gemini-2.5-pro",
});
});
});
@@ -0,0 +1,139 @@
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import type { FrontendEvalModelConfig } from "../../../../core/models";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
export type FrontendEvalProvider = FrontendEvalModelConfig["provider"];
export interface EvalClients {
openai: OpenAI;
anthropic: Anthropic;
}
export interface ResolvedEvalModelProvider {
provider: FrontendEvalProvider;
model: string;
}
export interface EvalProxyClientConfig {
baseURL: string;
bearerToken: string;
resourcePath: string;
}
const GEMINI_OPENAI_BASE_URL =
"https://generativelanguage.googleapis.com/v1beta/openai/";
const GEMINI_GOOG_API_CLIENT = "windmill-ai-evals/1.0";
const EVAL_PROXY_RESOURCE_PREFIX = "f/evals/ai";
export function buildProxyHeaders(
bearerToken: string,
resourcePath: string,
): Record<string, string> {
return {
Authorization: `Bearer ${bearerToken}`,
"X-Resource-Path": resourcePath,
};
}
export function buildProxyResourcePath(provider: FrontendEvalProvider): string {
return `${EVAL_PROXY_RESOURCE_PREFIX}/${provider}`;
}
export function buildOpenAICompatibleClientOptions(
provider: Exclude<FrontendEvalProvider, "anthropic">,
apiKey: string,
): ConstructorParameters<typeof OpenAI>[0] {
if (provider === "googleai") {
return {
apiKey,
baseURL: GEMINI_OPENAI_BASE_URL,
defaultHeaders: {
"x-goog-api-client": GEMINI_GOOG_API_CLIENT,
},
};
}
return { apiKey };
}
function buildProxyOpenAIClientOptions(
proxy: EvalProxyClientConfig,
): ConstructorParameters<typeof OpenAI>[0] {
return {
apiKey: "unused",
baseURL: proxy.baseURL,
defaultHeaders: buildProxyHeaders(proxy.bearerToken, proxy.resourcePath),
};
}
export function createEvalClients(input: {
provider: FrontendEvalProvider;
apiKey: string;
transport?: FrontendEvalTransport;
proxy?: EvalProxyClientConfig;
}): EvalClients {
const transport = input.transport ?? "direct";
if (input.provider === "anthropic") {
if (transport === "proxy") {
if (!input.proxy) {
throw new Error(
"Missing proxy client configuration for proxy transport",
);
}
return {
openai: new OpenAI({ apiKey: "unused" }),
anthropic: new Anthropic({
apiKey: "unused",
baseURL: input.proxy.baseURL,
defaultHeaders: buildProxyHeaders(
input.proxy.bearerToken,
input.proxy.resourcePath,
),
}),
};
}
return {
openai: new OpenAI({ apiKey: "unused" }),
anthropic: new Anthropic({ apiKey: input.apiKey }),
};
}
if (transport === "proxy") {
if (!input.proxy) {
throw new Error("Missing proxy client configuration for proxy transport");
}
return {
openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)),
anthropic: new Anthropic({ apiKey: "unused" }),
};
}
return {
openai: new OpenAI(
buildOpenAICompatibleClientOptions(input.provider, input.apiKey),
),
anthropic: new Anthropic({ apiKey: "unused" }),
};
}
export function resolveEvalModelProvider(
model: string,
provider?: FrontendEvalProvider,
): ResolvedEvalModelProvider {
if (provider) {
return { provider, model };
}
if (model.startsWith("claude")) {
return { provider: "anthropic", model };
}
if (model.startsWith("gemini")) {
return { provider: "googleai", model };
}
if (model.startsWith("gpt") || model.startsWith("o")) {
return { provider: "openai", model };
}
return { provider: "openai", model };
}
@@ -0,0 +1,38 @@
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs";
import type { AIProvider } from "$lib/gen/types.gen";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
export interface TokenUsage {
prompt: number;
completion: number;
total: number;
}
export interface ToolCallDetail {
name: string;
arguments: Record<string, unknown>;
}
export interface EvalRunnerOptions {
maxIterations?: number;
model?: string;
workspace?: string;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
proxyCaseId?: string;
proxyAttempt?: number;
}
export interface RawEvalResult<TOutput> {
success: boolean;
output: TOutput;
error?: string;
tokenUsage: TokenUsage;
toolCallsCount: number;
toolsCalled: string[];
toolCallDetails: ToolCallDetail[];
iterations: number;
messages: ChatCompletionMessageParam[];
}
+324
View File
@@ -0,0 +1,324 @@
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 { buildScriptLintResult } from './core/script/preview'
const BENCHMARK_TIMESTAMP = '1970-01-01T00:00:00.000Z'
export interface BenchmarkWorkspaceScript {
path: string
summary: string
description?: string
language: Script['language']
schema?: Record<string, unknown>
content: string
}
export interface BenchmarkWorkspaceFlow {
path: string
summary: string
description?: string
schema?: Record<string, unknown>
value: Flow['value']
}
export interface BenchmarkWorkspaceRunnables {
scripts?: BenchmarkWorkspaceScript[]
flows?: BenchmarkWorkspaceFlow[]
}
type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' }
const benchmarkWorkspaces = new Set<string>()
const benchmarkWorkspaceRunnables = new Map<string, BenchmarkWorkspaceRunnables>()
const benchmarkJobs = new Map<string, { workspace: string; job: BenchmarkCompletedJob }>()
export function resetBenchmarkMockBackend(): void {
benchmarkWorkspaces.clear()
benchmarkWorkspaceRunnables.clear()
benchmarkJobs.clear()
}
export function registerBenchmarkWorkspace(workspace: string): void {
benchmarkWorkspaces.add(workspace)
}
export function registerBenchmarkWorkspaceRunnables(
workspace: string,
runnables: BenchmarkWorkspaceRunnables
): void {
benchmarkWorkspaces.add(workspace)
benchmarkWorkspaceRunnables.set(workspace, runnables)
}
export function unregisterBenchmarkWorkspace(workspace: string): void {
benchmarkWorkspaces.delete(workspace)
benchmarkWorkspaceRunnables.delete(workspace)
for (const [jobId, entry] of benchmarkJobs.entries()) {
if (entry.workspace === workspace) {
benchmarkJobs.delete(jobId)
}
}
}
export function unregisterBenchmarkWorkspaceRunnables(workspace: string): void {
unregisterBenchmarkWorkspace(workspace)
}
export function hasBenchmarkWorkspace(workspace: string): boolean {
return benchmarkWorkspaces.has(workspace)
}
export function listBenchmarkScripts(workspace: string): Script[] | null {
const runnables = benchmarkWorkspaceRunnables.get(workspace)
if (!runnables) {
return null
}
return (runnables.scripts ?? []).map(buildBenchmarkScript)
}
export function listBenchmarkFlows(workspace: string): Flow[] | null {
const runnables = benchmarkWorkspaceRunnables.get(workspace)
if (!runnables) {
return null
}
return (runnables.flows ?? []).map(buildBenchmarkFlow)
}
export function getBenchmarkScriptByPath(workspace: string, path: string): Script | null {
const script = benchmarkWorkspaceRunnables
.get(workspace)
?.scripts?.find((entry) => entry.path === path)
return script ? buildBenchmarkScript(script) : null
}
export function getBenchmarkScriptByHash(workspace: string, hash: string): Script | null {
const script = benchmarkWorkspaceRunnables
.get(workspace)
?.scripts?.find((entry) => buildBenchmarkScriptHash(entry.path) === hash)
return script ? buildBenchmarkScript(script) : null
}
export function getBenchmarkFlowByPath(workspace: string, path: string): Flow | null {
const flow = benchmarkWorkspaceRunnables
.get(workspace)
?.flows?.find((entry) => entry.path === path)
return flow ? buildBenchmarkFlow(flow) : null
}
export function createBenchmarkCompletedJob(input: {
workspace: string
jobKind: CompletedJob['job_kind']
success?: boolean
result?: unknown
logs?: string
scriptPath?: string
scriptHash?: string
args?: Record<string, unknown>
}): string {
const jobId = `benchmark-job-${randomUUID()}`
const now = new Date().toISOString()
const job: BenchmarkCompletedJob = {
type: 'CompletedJob',
id: jobId,
workspace_id: input.workspace,
created_by: 'ai-evals',
created_at: now,
started_at: now,
completed_at: now,
duration_ms: 0,
success: input.success ?? true,
script_path: input.scriptPath,
script_hash: input.scriptHash,
args: input.args,
result: input.result,
logs: input.logs,
canceled: false,
job_kind: input.jobKind,
permissioned_as: 'u/ai-evals',
is_flow_step: false,
is_skipped: false,
email: 'ai-evals@local',
visible_to_owner: true,
tag: 'benchmark'
}
benchmarkJobs.set(jobId, { workspace: input.workspace, job })
return jobId
}
export function getBenchmarkCompletedJob(
workspace: string,
jobId: string
): BenchmarkCompletedJob | null {
const entry = benchmarkJobs.get(jobId)
if (!entry || entry.workspace !== workspace) {
return null
}
return structuredClone(entry.job)
}
export function runBenchmarkScriptPreview(input: {
workspace: string
requestBody: {
content?: string
language?: ScriptLang | 'bunnative'
args?: Record<string, unknown>
path?: string
}
}): string {
const content = input.requestBody.content ?? ''
const language = input.requestBody.language ?? 'bun'
const lintResult = buildScriptLintResult(content, language)
const success = lintResult.errorCount === 0
return createBenchmarkCompletedJob({
workspace: input.workspace,
jobKind: 'preview',
success,
scriptPath: input.requestBody.path,
args: input.requestBody.args,
result: success
? {
path: input.requestBody.path,
args: input.requestBody.args ?? {},
validated: true
}
: {
path: input.requestBody.path,
args: input.requestBody.args ?? {},
errorCount: lintResult.errorCount,
errors: lintResult.errors.map((entry) => ({
line: entry.startLineNumber,
message: entry.message
}))
}
})
}
export function runBenchmarkFlowByPath(input: {
workspace: string
path: string
args?: Record<string, unknown>
}): string {
const flow = getBenchmarkFlowByPath(input.workspace, input.path)
return createBenchmarkCompletedJob({
workspace: input.workspace,
jobKind: 'flowpreview',
success: flow !== null,
args: input.args,
result:
flow !== null
? {
path: input.path,
args: input.args ?? {},
mocked: true
}
: {
error: `Flow "${input.path}" not found in benchmark workspace`
},
logs:
flow !== null
? 'Mock benchmark flow run completed successfully.'
: `Flow "${input.path}" not found in benchmark workspace.`
})
}
export function previewBenchmarkSchedule(input: {
requestBody?: Record<string, unknown>
}): Record<string, unknown> {
const schedule = input.requestBody?.schedule
if (typeof schedule !== 'string' || schedule.trim().split(/\s+/).length !== 6) {
throw new Error(`schedule must use a six-field cron expression, got ${JSON.stringify(schedule)}`)
}
return {
next_runs: ['1970-01-02T00:00:00.000Z']
}
}
export function createBenchmarkSchedule(input: {
workspace: string
requestBody: Record<string, unknown>
}): Record<string, unknown> {
assertBenchmarkWorkspacePath('schedule', input.requestBody.path)
assertBenchmarkWorkspacePath('target', input.requestBody.script_path)
return {
path: input.requestBody.path,
target_path: input.requestBody.script_path,
is_flow: input.requestBody.is_flow,
mocked: true
}
}
export function createBenchmarkHttpTrigger(input: {
workspace: string
requestBody: Record<string, unknown>
}): Record<string, unknown> {
assertBenchmarkWorkspacePath('trigger', input.requestBody.path)
assertBenchmarkWorkspacePath('target', input.requestBody.script_path)
if (
typeof input.requestBody.route_path === 'string' &&
input.requestBody.route_path.startsWith('/')
) {
throw new Error(`HTTP trigger route_path must not start with /, got "${input.requestBody.route_path}"`)
}
return {
path: input.requestBody.path,
target_path: input.requestBody.script_path,
route_path: input.requestBody.route_path,
is_flow: input.requestBody.is_flow,
mocked: true
}
}
function assertBenchmarkWorkspacePath(label: string, value: unknown): void {
if (typeof value !== 'string' || (!value.startsWith('f/') && !value.startsWith('u/'))) {
throw new Error(`${label} path must start with f/ or u/, got ${JSON.stringify(value)}`)
}
}
function buildBenchmarkScriptHash(path: string): string {
return `benchmark:${path}`
}
function buildBenchmarkScript(script: BenchmarkWorkspaceScript): Script {
return {
workspace_id: 'benchmark',
hash: buildBenchmarkScriptHash(script.path),
path: script.path,
parent_hashes: [],
summary: script.summary,
description: script.description ?? '',
content: script.content,
created_by: 'benchmark',
created_at: BENCHMARK_TIMESTAMP,
archived: false,
schema: script.schema ?? {},
deleted: false,
is_template: false,
extra_perms: {},
language: script.language,
kind: 'script',
starred: false,
has_preprocessor: false,
modules: null
}
}
function buildBenchmarkFlow(flow: BenchmarkWorkspaceFlow): Flow {
return {
path: flow.path,
summary: flow.summary,
description: flow.description ?? '',
value: flow.value,
schema: flow.schema ?? {},
edited_by: 'benchmark',
edited_at: BENCHMARK_TIMESTAMP,
archived: false,
extra_perms: {}
} as Flow
}
+146
View File
@@ -0,0 +1,146 @@
export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script'
export type FrontendBenchmarkProgressEvent =
| {
type: 'run-start'
surface: FrontendBenchmarkProgressSurface
totalCases: number
runs: number
concurrency: number
}
| {
type: 'attempt-start'
surface: FrontendBenchmarkProgressSurface
caseId: string
caseNumber: number
totalCases: number
attempt: number
runs: number
}
| {
type: 'attempt-finish'
surface: FrontendBenchmarkProgressSurface
caseId: string
caseNumber: number
totalCases: number
attempt: number
runs: number
passed: boolean
durationMs: number
judgeScore: number | null
error: string | null
}
| {
type: 'assistant-message-start'
surface: FrontendBenchmarkProgressSurface
caseId: string
caseNumber: number
totalCases: number
attempt: number
runs: number
}
| {
type: 'assistant-chunk'
surface: FrontendBenchmarkProgressSurface
caseId: string
caseNumber: number
totalCases: number
attempt: number
runs: number
chunk: string
}
| {
type: 'assistant-message-end'
surface: FrontendBenchmarkProgressSurface
caseId: string
caseNumber: number
totalCases: number
attempt: number
runs: number
}
| {
type: 'tool-call'
surface: FrontendBenchmarkProgressSurface
caseId: string
caseNumber: number
totalCases: number
attempt: number
runs: number
toolName: string
argumentsText: string
}
export const FRONTEND_BENCHMARK_PROGRESS_PREFIX = 'WMILL_FRONTEND_AI_EVAL_PROGRESS '
export function emitFrontendBenchmarkProgress(event: FrontendBenchmarkProgressEvent): void {
process.stderr.write(
`${FRONTEND_BENCHMARK_PROGRESS_PREFIX}${JSON.stringify(event)}\n`
)
}
export function parseFrontendBenchmarkProgressLine(
line: string
): FrontendBenchmarkProgressEvent | null {
if (!line.startsWith(FRONTEND_BENCHMARK_PROGRESS_PREFIX)) {
return null
}
try {
const parsed = JSON.parse(
line.slice(FRONTEND_BENCHMARK_PROGRESS_PREFIX.length)
) as FrontendBenchmarkProgressEvent
return parsed?.type ? parsed : null
} catch {
return null
}
}
export function formatFrontendBenchmarkProgressEvent(
event: FrontendBenchmarkProgressEvent
): string {
switch (event.type) {
case 'run-start':
return `Running ${event.surface}: ${event.totalCases} cases x ${event.runs} run${event.runs === 1 ? '' : 's'}, concurrency ${event.concurrency}`
case 'attempt-start':
return `${formatCasePrefix(event.caseNumber, event.totalCases)} ${event.caseId} attempt ${event.attempt}/${event.runs}...`
case 'attempt-finish': {
const parts = [
`${formatCasePrefix(event.caseNumber, event.totalCases)} ${event.caseId} attempt ${event.attempt}/${event.runs} ${event.passed ? 'pass' : 'fail'}`,
formatDuration(event.durationMs)
]
if (event.judgeScore !== null) {
parts.push(`judge ${formatNumber(event.judgeScore)}`)
}
if (event.error) {
parts.push(truncateSingleLine(event.error, 120))
}
return parts.join(' | ')
}
case 'assistant-message-start':
case 'assistant-chunk':
case 'assistant-message-end':
return ''
case 'tool-call':
return `${formatCasePrefix(event.caseNumber, event.totalCases)} ${event.caseId} attempt ${event.attempt}/${event.runs} tool ${event.toolName} ${truncateSingleLine(event.argumentsText, 200)}`
}
}
function formatCasePrefix(caseNumber: number, totalCases: number): string {
return `[${caseNumber}/${totalCases}]`
}
function formatDuration(durationMs: number): string {
return `${formatNumber(durationMs / 1000)}s`
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
function truncateSingleLine(value: string, maxLength: number): string {
const normalized = value.replace(/\s+/g, ' ').trim()
if (normalized.length <= maxLength) {
return normalized
}
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`
}
+228
View File
@@ -0,0 +1,228 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
formatFrontendBenchmarkProgressEvent,
parseFrontendBenchmarkProgressLine,
} from "./progress";
import type { BenchmarkRunResult } from "../../core/types";
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
const FRONTEND_DIR = path.join(REPO_ROOT, "frontend");
const FRONTEND_BENCHMARK_TEST =
"../ai_evals/adapters/frontend/vitestAdapter.test.ts";
const FRONTEND_BENCHMARK_CONFIG =
"../ai_evals/adapters/frontend/vitest.config.ts";
export type FrontendMode = "flow" | "app" | "script";
export async function runFrontendBenchmarkAdapter(input: {
mode: FrontendMode;
caseIds: string[];
runs: number;
model?: string;
transport?: string;
verbose?: boolean;
backendValidation?: string;
}): Promise<BenchmarkRunResult> {
const tempDir = await mkdtemp(
path.join(tmpdir(), "wmill-frontend-benchmark-"),
);
const outputPath = path.join(tempDir, "result.json");
const env: NodeJS.ProcessEnv = {
...process.env,
BROWSERSLIST_IGNORE_OLD_DATA: "1",
WMILL_FRONTEND_AI_EVAL_OUTPUT_PATH: outputPath,
WMILL_FRONTEND_AI_EVAL_MODE: input.mode,
WMILL_FRONTEND_AI_EVAL_CASE_IDS: JSON.stringify(input.caseIds),
WMILL_FRONTEND_AI_EVAL_RUNS: String(input.runs),
WMILL_FRONTEND_AI_EVAL_MODEL: input.model ?? "",
WMILL_FRONTEND_AI_EVAL_PROGRESS: "1",
WMILL_FRONTEND_AI_EVAL_VERBOSE: input.verbose ? "1" : "0",
WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION: input.backendValidation ?? "",
};
if (input.transport) {
env.WMILL_FRONTEND_AI_EVAL_TRANSPORT = input.transport;
}
try {
await runVitestBenchmark(
path.join(FRONTEND_DIR, "node_modules", ".bin", "vitest"),
[
"run",
FRONTEND_BENCHMARK_TEST,
"--project",
"server",
"--config",
FRONTEND_BENCHMARK_CONFIG,
],
{
cwd: FRONTEND_DIR,
env,
},
);
const raw = await readFile(outputPath, "utf8");
return JSON.parse(raw) as BenchmarkRunResult;
} catch (error) {
throw new Error(
`Frontend benchmark adapter failed:\n${toErrorMessage(error)}`,
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
async function runVitestBenchmark(
command: string,
args: string[],
options: {
cwd: string;
env: NodeJS.ProcessEnv;
},
): Promise<void> {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stderrLineBuffer = "";
let assistantStreamOpen = false;
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
stderrLineBuffer += chunk;
const { remainder, passthrough, nextAssistantStreamOpen } =
drainProgressLines(stderrLineBuffer, assistantStreamOpen);
stderrLineBuffer = remainder;
stderr += passthrough;
assistantStreamOpen = nextAssistantStreamOpen;
});
await new Promise<void>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code) => {
if (stderrLineBuffer.length > 0) {
const { remainder, passthrough, nextAssistantStreamOpen } =
drainProgressLines(`${stderrLineBuffer}\n`, assistantStreamOpen);
stderrLineBuffer = remainder;
stderr += passthrough;
assistantStreamOpen = nextAssistantStreamOpen;
}
if (code === 0) {
if (assistantStreamOpen) {
process.stderr.write("\n");
}
resolve();
return;
}
const details = [`vitest exited with code ${code}`, stdout, stderr]
.filter(Boolean)
.join("\n");
reject(new Error(details));
});
});
}
function drainProgressLines(
buffer: string,
initialAssistantStreamOpen: boolean,
): {
remainder: string;
passthrough: string;
nextAssistantStreamOpen: boolean;
} {
let remainder = buffer;
let passthrough = "";
let assistantStreamOpen = initialAssistantStreamOpen;
while (true) {
const newlineIndex = remainder.indexOf("\n");
if (newlineIndex === -1) {
return {
remainder,
passthrough,
nextAssistantStreamOpen: assistantStreamOpen,
};
}
const line = remainder.slice(0, newlineIndex).replace(/\r$/, "");
remainder = remainder.slice(newlineIndex + 1);
const progressEvent = parseFrontendBenchmarkProgressLine(line);
if (progressEvent) {
if (progressEvent.type === "assistant-message-start") {
if (assistantStreamOpen) {
process.stderr.write("\n");
}
process.stderr.write(
`${formatCasePrefix(progressEvent.caseNumber, progressEvent.totalCases)} ${progressEvent.caseId} attempt ${progressEvent.attempt}/${progressEvent.runs} assistant:\n`,
);
assistantStreamOpen = true;
continue;
}
if (progressEvent.type === "assistant-chunk") {
process.stderr.write(progressEvent.chunk);
continue;
}
if (progressEvent.type === "assistant-message-end") {
if (assistantStreamOpen) {
process.stderr.write("\n");
}
assistantStreamOpen = false;
continue;
}
if (assistantStreamOpen) {
process.stderr.write("\n");
assistantStreamOpen = false;
}
process.stderr.write(
`${formatFrontendBenchmarkProgressEvent(progressEvent)}\n`,
);
continue;
}
if (shouldSuppressFrontendStderrLine(line)) {
continue;
}
passthrough += `${line}\n`;
process.stderr.write(`${line}\n`);
}
}
function formatCasePrefix(caseNumber: number, totalCases: number): string {
return `[${caseNumber}/${totalCases}]`;
}
function shouldSuppressFrontendStderrLine(line: string): boolean {
return (
line.startsWith("[baseline-browser-mapping] ") ||
line.startsWith("Browserslist: browsers data (caniuse-lite) is ") ||
line.includes("update-browserslist-db@latest") ||
line.includes("update-db#readme")
);
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
@@ -0,0 +1,28 @@
import { fileURLToPath } from 'node:url'
import frontendConfig from '../../../frontend/vite.config.js'
const FRONTEND_VITE_CONFIG_PATH = fileURLToPath(new URL('../../../frontend/vite.config.js', import.meta.url))
const FRONTEND_TEST_SETUP_PATH = fileURLToPath(
new URL('../../../frontend/src/lib/test-setup.ts', import.meta.url)
)
const ADAPTER_TEST_PATH = fileURLToPath(new URL('./vitestAdapter.test.ts', import.meta.url))
const config = {
...frontendConfig,
test: {
...frontendConfig.test,
projects: [
{
extends: FRONTEND_VITE_CONFIG_PATH,
test: {
name: 'server',
environment: 'node',
include: [ADAPTER_TEST_PATH],
setupFiles: [FRONTEND_TEST_SETUP_PATH]
}
}
]
}
}
export default config
@@ -0,0 +1,182 @@
import { expect, it, vi } from 'vitest'
// @ts-ignore - Node.js fs/promises
import { mkdir, writeFile } from 'fs/promises'
// @ts-ignore - Node.js path
import { dirname, resolve } from 'path'
vi.mock('monaco-editor', () => ({
editor: {},
languages: {},
KeyCode: {},
Uri: {
parse: (value: string) => ({ toString: () => value })
},
MarkerSeverity: {
Error: 8,
Warning: 4,
Info: 2,
Hint: 1
}
}))
vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({
getTypeScriptWorker: async () => async () => ({}),
typescriptVersion: 'test'
}))
vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({
default: () => ({})
}))
vi.mock('$lib/components/vscode', () => ({}))
vi.mock('$lib/gen', async () => {
const actual = await vi.importActual<any>('$lib/gen')
const {
getBenchmarkCompletedJob,
getBenchmarkFlowByPath,
getBenchmarkScriptByHash,
getBenchmarkScriptByPath,
hasBenchmarkWorkspace,
listBenchmarkFlows,
listBenchmarkScripts,
createBenchmarkHttpTrigger,
createBenchmarkSchedule,
previewBenchmarkSchedule,
runBenchmarkFlowByPath,
runBenchmarkScriptPreview
} = await import('./mockBackend')
function wrapService<T extends object>(target: T, overrides: Record<string, unknown>): T {
return new Proxy(target, {
get(source, property, receiver) {
if (typeof property === 'string' && property in overrides) {
return overrides[property]
}
return Reflect.get(source, property, receiver)
}
})
}
return {
...actual,
ScriptService: wrapService(actual.ScriptService, {
listScripts: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkScripts(data.workspace) ?? [])
: actual.ScriptService.listScripts(data),
getScriptByPath: 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.getScriptByPath(data)
},
getScriptByHash: async (data: { workspace: string; hash: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const script = getBenchmarkScriptByHash(data.workspace, data.hash)
if (!script) {
throw new Error(`Script hash "${data.hash}" not found in benchmark workspace`)
}
return script
}
return actual.ScriptService.getScriptByHash(data)
}
}),
FlowService: wrapService(actual.FlowService, {
listFlows: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkFlows(data.workspace) ?? [])
: actual.FlowService.listFlows(data),
getFlowByPath: 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.getFlowByPath(data)
}
}),
JobService: wrapService(actual.JobService, {
runScriptPreview: async (data: {
workspace: string
requestBody?: {
content?: string
language?: string
args?: Record<string, unknown>
path?: string
}
}) =>
hasBenchmarkWorkspace(data.workspace)
? runBenchmarkScriptPreview({
workspace: data.workspace,
requestBody: data.requestBody ?? {}
})
: actual.JobService.runScriptPreview(data),
runFlowByPath: async (data: {
workspace: string
path: string
requestBody?: Record<string, unknown>
}) =>
hasBenchmarkWorkspace(data.workspace)
? runBenchmarkFlowByPath({
workspace: data.workspace,
path: data.path,
args: data.requestBody
})
: actual.JobService.runFlowByPath(data),
getJob: async (data: { workspace: string; id: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const job = getBenchmarkCompletedJob(data.workspace, data.id)
if (!job) {
throw new Error(`Job "${data.id}" not found in benchmark workspace`)
}
return job
}
return actual.JobService.getJob(data)
}
}),
ScheduleService: wrapService(actual.ScheduleService, {
previewSchedule: async (data: { requestBody?: Record<string, unknown> }) =>
previewBenchmarkSchedule(data),
createSchedule: async (data: { workspace: string; requestBody: Record<string, unknown> }) =>
hasBenchmarkWorkspace(data.workspace)
? createBenchmarkSchedule(data)
: actual.ScheduleService.createSchedule(data)
}),
HttpTriggerService: wrapService(actual.HttpTriggerService, {
createHttpTrigger: async (data: { workspace: string; requestBody: Record<string, unknown> }) =>
hasBenchmarkWorkspace(data.workspace)
? createBenchmarkHttpTrigger(data)
: actual.HttpTriggerService.createHttpTrigger(data)
})
}
})
const benchmarkOutputPath = process.env.WMILL_FRONTEND_AI_EVAL_OUTPUT_PATH
const benchmarkIt = benchmarkOutputPath ? it : it.skip
benchmarkIt(
'runs the frontend benchmark adapter from environment input',
async () => {
const { resetBenchmarkMockBackend } = await import('./mockBackend')
resetBenchmarkMockBackend()
const { runFrontendBenchmarkFromEnv } = await import('./benchmarkRunner')
try {
const payload = await runFrontendBenchmarkFromEnv()
const absoluteOutputPath = resolve(benchmarkOutputPath!)
await mkdir(dirname(absoluteOutputPath), { recursive: true })
await writeFile(absoluteOutputPath, JSON.stringify(payload, null, 2) + '\n', 'utf8')
expect(payload.cases.length).toBeGreaterThan(0)
} finally {
resetBenchmarkMockBackend()
}
},
600_000
)
@@ -0,0 +1,184 @@
import { randomUUID } from "node:crypto";
import type { WindmillBackendSettings } from "../../core/windmillBackendSettings";
const tokenCache = new Map<string, Promise<string>>();
const sharedWorkspaceQueue = new Map<string, Promise<void>>();
export class WindmillBackendClient {
constructor(private readonly settings: WindmillBackendSettings) {}
async withWorkspace<T>(
caseId: string,
attempt: number,
body: (workspaceId: string) => Promise<T>,
): Promise<T> {
const workspaceId =
this.settings.workspaceOverride ??
buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt);
const run = async () => {
await this.ensureWorkspace(workspaceId);
try {
return await body(workspaceId);
} finally {
if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) {
await this.deleteWorkspace(workspaceId).catch(() => undefined);
}
}
};
if (this.settings.workspaceOverride) {
return await withSharedWorkspaceLock(workspaceId, run);
}
return await run();
}
async request(path: string, init?: RequestInit): Promise<Response> {
const token = await this.getToken();
return await fetch(`${this.settings.baseUrl}/api${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init?.headers ?? {}),
},
});
}
async getToken(): Promise<string> {
const cacheKey = `${this.settings.baseUrl}|${this.settings.email}`;
let tokenPromise = tokenCache.get(cacheKey);
if (!tokenPromise) {
tokenPromise = this.login().catch((error) => {
if (tokenCache.get(cacheKey) === tokenPromise) {
tokenCache.delete(cacheKey);
}
throw error;
});
tokenCache.set(cacheKey, tokenPromise);
}
return await tokenPromise;
}
async upsertResource(input: {
workspaceId: string;
path: string;
resourceType: string;
value: Record<string, unknown>;
}): Promise<void> {
const response = await this.request(
`/w/${encodeURIComponent(input.workspaceId)}/resources/create?update_if_exists=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: input.path,
resource_type: input.resourceType,
value: input.value,
}),
},
);
await expectOk(response, `upsert resource ${input.path}`);
}
private async ensureWorkspace(workspaceId: string): Promise<void> {
const existsResponse = await this.request("/workspaces/exists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: workspaceId }),
});
await expectOk(existsResponse, `check workspace ${workspaceId}`);
if ((await existsResponse.text()).trim() === "true") {
return;
}
const createResponse = await this.request("/workspaces/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: workspaceId, name: workspaceId }),
});
try {
await expectOk(createResponse, `create workspace ${workspaceId}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("maximum number of workspaces")) {
throw new Error(
`${message}. Reuse an existing workspace with WMILL_AI_EVAL_BACKEND_WORKSPACE=<workspace-id>.`,
);
}
throw error;
}
}
private async deleteWorkspace(workspaceId: string): Promise<void> {
const response = await this.request(
`/workspaces/delete/${encodeURIComponent(workspaceId)}`,
{
method: "DELETE",
},
);
await expectOk(response, `delete workspace ${workspaceId}`);
}
private async login(): Promise<string> {
const response = await fetch(`${this.settings.baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: this.settings.email,
password: this.settings.password,
}),
});
await expectOk(response, "login to Windmill backend");
return (await response.text()).trim();
}
}
async function withSharedWorkspaceLock<T>(
workspaceId: string,
body: () => Promise<T>,
): Promise<T> {
const previous = sharedWorkspaceQueue.get(workspaceId) ?? Promise.resolve();
let releaseCurrent: (() => void) | undefined;
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
const tail = previous.catch(() => undefined).then(() => current);
sharedWorkspaceQueue.set(workspaceId, tail);
await previous.catch(() => undefined);
try {
return await body();
} finally {
releaseCurrent?.();
if (sharedWorkspaceQueue.get(workspaceId) === tail) {
sharedWorkspaceQueue.delete(workspaceId);
}
}
}
function buildWorkspaceId(
prefix: string,
caseId: string,
attempt: number,
): string {
const caseSlug = caseId
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
const suffix = randomUUID().slice(0, 8);
return `${prefix}-${caseSlug || "case"}-a${attempt}-${suffix}`;
}
async function expectOk(response: Response, context: string): Promise<void> {
if (response.ok) {
return;
}
throw new Error(
`${context} failed: ${response.status} ${response.statusText} - ${await response.text()}`,
);
}
+313
View File
@@ -0,0 +1,313 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "windmill-ai-evals",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.25",
"@anthropic-ai/sdk": "^0.39.0",
"commander": "^14.0.3",
"openai": "^6.9.1",
"yaml": "^2.8.3",
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.0.0",
},
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.87", "", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-WWmgBPxPhBOvNT0ujI8vPTI2lK+w5YEkEZ/y1mH0EDkK/0kBnxVJNhCtG5vnueiAViwLoUOFn66pbkDiivijdA=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openai": ["openai@6.34.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"@anthropic-ai/claude-agent-sdk/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="],
"@types/node-fetch/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
}
}
+326
View File
@@ -0,0 +1,326 @@
- id: app-test1-counter-create
prompt: |-
Create a simple counter app with increment and decrement buttons.
judgeChecklist:
- shows the current count in the UI
- includes an increment button
- includes a decrement button
- clicking the buttons updates the count correctly
- id: app-test2-counter-reset
prompt: |-
Add a reset button that sets the counter back to 0
initial: ai_evals/fixtures/frontend/app/initial/test1_counter_app
judgeChecklist:
- adds a reset control to the existing counter app
- clicking reset sets the count back to 0
- keeps the existing increment and decrement behavior working
- id: app-test3-shopping-cart-quantity
prompt: |-
Add a quantity selector (+ and - buttons) to each cart item so users can adjust quantities without removing and re-adding items
initial: ai_evals/fixtures/frontend/app/initial/shopping_cart
judgeChecklist:
- each cart item has visible plus and minus quantity controls
- users can increase quantity without re-adding the product
- users can decrease quantity from the cart UI
- cart totals stay in sync with quantity changes
- id: app-test4-shopping-cart-discount
prompt: |-
Add a discount code input field in the cart.
When the code "SAVE10" is entered, apply a 10% discount to the total
initial: ai_evals/fixtures/frontend/app/initial/shopping_cart
judgeChecklist:
- adds a discount code input to the cart
- recognizes the code SAVE10
- applies a 10 percent discount to the displayed total
- keeps the rest of the cart behavior intact
- id: app-test5-file-manager-search
prompt: |-
Add a search bar in the toolbar that filters files and folders by name as the user types
initial: ai_evals/fixtures/frontend/app/initial/file_manager
judgeChecklist:
- adds a search input in the toolbar
- filters files and folders by name as the user types
- updates the visible file list from the search query
- keeps the rest of the file manager usable
- id: app-test6-file-manager-rename-save-cancel
prompt: |-
Improve the existing inline rename flow for files and folders.
When renaming, show explicit Save and Cancel buttons next to the name input.
Pressing Enter should save, pressing Escape should cancel, and Cancel should restore the original name without calling rename.
Keep the existing backend rename behavior for successful saves.
initial: ai_evals/fixtures/frontend/app/initial/file_manager
validate:
requiredFrontendPaths:
- /index.tsx
- /components/FileItem.tsx
requiredFrontendFileContent:
- path: /components/FileItem.tsx
includes:
- Save
- Cancel
- Escape
forbiddenAppContent:
- onBlur={handleRename}
judgeChecklist:
- keeps the existing visible rename action in the file list
- shows explicit Save and Cancel controls while editing a name
- pressing Enter saves the new name through the existing rename behavior
- pressing Escape or Cancel exits rename mode and restores the original name without saving
- id: app-test7-file-manager-select-all
prompt: |-
Add a "Select All" checkbox in the file list header and individual checkboxes for each file.
Add a "Delete Selected" button that appears when items are selected
initial: ai_evals/fixtures/frontend/app/initial/file_manager
judgeChecklist:
- adds a select-all control in the file list header
- adds per-item selection controls
- shows a delete-selected action only when there is a selection
- deleting selected items updates the visible list
- id: app-test8-inventory-tracker-search-delete
prompt: |-
Update this inventory tracker app so users can search items by name or sku and delete existing items.
Keep the existing add-item flow and datatable-backed persistence working.
initial: ai_evals/fixtures/frontend/app/initial/inventory_tracker
validate:
requiredFrontendPaths:
- /index.tsx
requiredBackendRunnableKeys:
- listInventory
- addInventory
- deleteInventory
requiredBackendRunnableTypes:
- key: listInventory
type: inline
- key: addInventory
type: inline
- key: deleteInventory
type: inline
requiredDatatables:
- datatableName: main
schema: public
table: inventory_items
judgeChecklist:
- keeps the existing add-item form working
- adds a search input that filters inventory by name or sku
- adds a delete action for existing inventory items
- deleting an inventory item updates the visible list
- keeps inventory persistence working through the existing datatable-backed app setup
- id: app-test9-recipe-book-search-delete
prompt: |-
Update this recipe book app so users can search recipes by name and delete existing recipes.
Keep the existing add-recipe flow and datatable-backed persistence working.
initial: ai_evals/fixtures/frontend/app/initial/recipe_book
validate:
requiredFrontendPaths:
- /index.tsx
requiredBackendRunnableKeys:
- listRecipes
- addRecipe
- deleteRecipe
requiredBackendRunnableTypes:
- key: listRecipes
type: inline
- key: addRecipe
type: inline
- key: deleteRecipe
type: inline
requiredDatatables:
- datatableName: main
schema: public
table: recipes
judgeChecklist:
- keeps the existing add-recipe form working
- adds a search input that filters recipes by name
- adds a delete action for existing recipes
- deleting a recipe updates the visible list
- keeps recipe persistence working through the existing datatable-backed app setup
- id: app-datatable-persistent-notes
prompt: |-
Build a notes app that persists notes in the existing datatable table.
Inspect the existing datatable and table schema before writing code.
Use the existing main/public.notes table, and do not create any new tables.
Create backend runnables named exactly listNotes, addNote, and deleteNote.
The UI should list notes, add a note with title/body, and delete notes.
Do not use localStorage, sessionStorage, IndexedDB, or in-memory-only persistence.
initial: ai_evals/fixtures/frontend/app/initial/notes_datatable
runtime:
maxTurns: 10
validate:
requiredFrontendPaths:
- /index.tsx
requiredFrontendFileContent:
- path: /index.tsx
includes:
- backend.listNotes
- backend.addNote
- backend.deleteNote
requiredBackendRunnableKeys:
- listNotes
- addNote
- deleteNote
requiredBackendRunnableTypes:
- key: listNotes
type: inline
- key: addNote
type: inline
- key: deleteNote
type: inline
requiredBackendRunnableContent:
- key: listNotes
includes:
- wmill.datatable
- select
- notes
- key: addNote
includes:
- wmill.datatable
- insert
- notes
- key: deleteNote
includes:
- wmill.datatable
- delete
- notes
datatableTableCountExactly: 1
requiredDatatables:
- datatableName: main
schema: public
table: notes
requiredToolsUsed:
- list_datatables
- get_datatable_table_schema
forbiddenAppContent:
- localStorage
- sessionStorage
- indexedDB
judgeChecklist:
- creates a notes UI that lists notes from the backend
- adds notes through backend datatable persistence
- deletes notes through backend datatable persistence
- reuses the existing main/public.notes table without creating new tables
- does not use browser storage or in-memory-only persistence as the source of truth
- id: app-test10-session-id-no-crypto
prompt: |-
Update `generateSessionId` so it no longer uses `crypto.randomUUID()`.
Make it return a handmade string id built from the current time and random characters.
Keep the existing sessionStorage-based chat session behavior unchanged.
initial: ai_evals/fixtures/frontend/app/initial/session_id_chat
runtime:
maxTurns: 4
validate:
requiredFrontendPaths:
- /index.tsx
requiredBackendRunnableKeys:
- a
requiredBackendRunnableTypes:
- key: a
type: inline
judgeChecklist:
- generateSessionId no longer calls crypto.randomUUID
- generateSessionId returns a handmade string id without using crypto
- getSessionId still stores and reuses chat_session_id in sessionStorage
- the existing new chat and send message session behavior remains wired up
- id: app-token-baseline-large-app-small-edit
prompt: |-
Change the main heading from "Analytics Console" to "Operations Console".
Keep the existing filtering, summary loading, and backend calls unchanged.
initial: ai_evals/fixtures/frontend/app/initial/token_heavy_context
runtime:
maxTurns: 8
validate:
requiredFrontendPaths:
- /index.tsx
requiredBackendRunnableKeys:
- loadAnalytics
- refreshSummary
judgeChecklist:
- changes the visible main heading to Operations Console
- keeps the existing summary loading behavior wired to loadAnalytics
- keeps the existing filter input and metric list behavior intact
- id: app-token-many-datatable-context
prompt: |-
Add a short note under the dashboard heading that says "Using existing analytics tables".
Do not create any new tables.
initial: ai_evals/fixtures/frontend/app/initial/token_heavy_datatables
runtime:
maxTurns: 8
appContext:
additional:
- type: datatable
datatableName: main
schema: analytics
table: event_log_01
- type: datatable
datatableName: main
schema: analytics
table: event_log_02
- type: datatable
datatableName: main
schema: analytics
table: event_log_03
- type: datatable
datatableName: main
schema: analytics
table: event_log_04
- type: datatable
datatableName: main
schema: analytics
table: event_log_05
- type: datatable
datatableName: main
schema: analytics
table: event_log_06
- type: datatable
datatableName: main
schema: analytics
table: event_log_07
- type: datatable
datatableName: main
schema: analytics
table: event_log_08
- type: datatable
datatableName: main
schema: operations
table: ops_record_13
- type: datatable
datatableName: main
schema: operations
table: ops_record_14
validate:
requiredFrontendPaths:
- /index.tsx
datatableCountAtLeast: 1
datatableTableCountAtLeast: 18
judgeChecklist:
- adds the note Using existing analytics tables under or near the heading
- does not create new datatable tables
- keeps the configured datatable references available in the app artifact
- id: app-token-large-datatable-discovery
prompt: |-
Build a read-only dashboard page that reuses the existing analytics datatable tables.
Show a simple summary of which existing tables are available, and do not create any new tables.
initial: ai_evals/fixtures/frontend/app/initial/token_heavy_datatables
runtime:
maxTurns: 8
validate:
requiredFrontendPaths:
- /index.tsx
datatableCountAtLeast: 1
datatableTableCountAtLeast: 18
judgeChecklist:
- reuses the existing datatable configuration rather than creating new tables
- presents a read-only dashboard or summary of available analytics data
- keeps the configured datatable references available in the app artifact
+199
View File
@@ -0,0 +1,199 @@
- id: bun-hello-script
prompt: |-
Create a Windmill Bun script at `f/evals/hello.ts`.
It should take a `name` input and return a greeting object like `{ greeting: "Hello, Alice!" }`.
expected: ai_evals/fixtures/cli/expected/bun-hello-script
cliExpect:
requiredSkills:
- write-script-bun
requiredSkillsBeforeFirstMutation:
- write-script-bun
forbiddenSkills:
- write-script-python3
- write-flow
orderedAssistantMentions:
- wmill generate-metadata
- wmill sync push
orderedProposedCommands:
- wmill generate-metadata
- wmill sync push
forbiddenExecutedCommands:
- ^wmill generate-metadata
- ^wmill sync push
judgeChecklist:
- creates the requested Bun script at f/evals/hello.ts
- takes a name input
- returns an object containing the greeting
- id: bun-hello-flow
prompt: |-
Create a Windmill flow at `f/evals/hello__flow`.
It should take a `name` input and return a greeting object like `{ greeting: "Hello, Alice!" }`.
Put the step code in `hello.ts`.
expected: ai_evals/fixtures/cli/expected/bun-hello-flow
cliExpect:
requiredSkills:
- write-flow
requiredSkillsBeforeFirstMutation:
- write-flow
forbiddenSkills:
- write-script-python3
orderedAssistantMentions:
- wmill generate-metadata
- wmill sync push
orderedProposedCommands:
- wmill generate-metadata
- wmill sync push
forbiddenExecutedCommands:
- ^wmill generate-metadata
- ^wmill sync push
judgeChecklist:
- creates the requested flow folder with flow.yaml and hello.ts
- wires the name input into the flow step
- returns the greeting object
- id: python-add-numbers-script
prompt: |-
Add a Windmill Python script at `f/evals/add_numbers.py`.
It should take `a` and `b` as inputs and return `{ "total": a + b }`.
expected: ai_evals/fixtures/cli/expected/python-add-numbers-script
cliExpect:
requiredSkills:
- write-script-python3
requiredSkillsBeforeFirstMutation:
- write-script-python3
forbiddenSkills:
- write-script-bun
- write-flow
orderedAssistantMentions:
- wmill generate-metadata
- wmill sync push
orderedProposedCommands:
- wmill generate-metadata
- wmill sync push
forbiddenExecutedCommands:
- ^wmill generate-metadata
- ^wmill sync push
judgeChecklist:
- creates the requested Python script at f/evals/add_numbers.py
- takes `a` and `b` as inputs
- returns an object with total equal to a plus b
- id: bun-hello-script-uppercase
prompt: |-
Update `f/evals/hello.ts` so it accepts an optional `uppercase` boolean.
Keep returning `{ greeting: ... }`, but when `uppercase` is true the greeting should be uppercased before returning it.
initial: ai_evals/fixtures/cli/initial/bun-hello-script-uppercase
expected: ai_evals/fixtures/cli/expected/bun-hello-script-uppercase
judgeChecklist:
- updates the existing hello.ts file rather than creating a new script
- accepts an optional uppercase boolean input
- keeps returning an object with greeting
- uppercases the greeting when uppercase is true
- id: bun-hello-flow-punctuation
prompt: |-
Update the existing flow in `f/evals/hello__flow` so it also accepts an optional `punctuation` input.
The greeting should use that punctuation and default to `!` when it is missing.
initial: ai_evals/fixtures/cli/initial/bun-hello-flow-punctuation
expected: ai_evals/fixtures/cli/expected/bun-hello-flow-punctuation
judgeChecklist:
- updates the existing hello flow instead of creating a new one
- adds an optional punctuation input to the flow
- updates the step code so the returned greeting uses punctuation
- defaults punctuation to an exclamation mark when omitted
- id: flow-reuse-existing-script
prompt: |-
There is already a reusable greeting script at `f/lib/format_greeting.ts`.
Create a flow at `f/evals/reuse_greeting__flow` that takes a `name` input and reuses that existing script instead of duplicating the logic inline.
initial: ai_evals/fixtures/cli/initial/flow-reuse-existing-script
expected: ai_evals/fixtures/cli/expected/flow-reuse-existing-script
cliExpect:
requiredSkills:
- write-flow
requiredSkillsBeforeFirstMutation:
- write-flow
orderedAssistantMentions:
- wmill generate-metadata
- wmill sync push
orderedProposedCommands:
- wmill generate-metadata
- wmill sync push
forbiddenExecutedCommands:
- ^wmill generate-metadata
- ^wmill sync push
judgeChecklist:
- creates the requested flow at f/evals/reuse_greeting__flow
- reuses the existing script from f/lib by path
- does not duplicate the greeting logic in a new inline script
- wires the name input into the reused script
- id: cli-job-debug-guidance
prompt: |-
A Windmill job failed.
Tell me exactly which `wmill` commands to run to inspect the job details, logs, and final result for job ID `123`.
Do not modify any files.
cliExpect:
requiredSkills:
- cli-commands
workspaceUnchanged: true
orderedProposedCommands:
- wmill job get 123
- wmill job logs 123
- wmill job result 123
forbiddenProposedCommands:
- wmill sync push
forbiddenExecutedCommands:
- ^wmill job get
- ^wmill job logs
- ^wmill job result
judgeChecklist:
- does not modify the workspace
- recommends commands to inspect the job details
- recommends commands to inspect the job logs
- recommends commands to inspect the final result
- id: cli-sync-pull-guidance
prompt: |-
I want to review remote workspace changes before editing locally.
Tell me the first `wmill` command I should run.
Do not modify any files.
cliExpect:
requiredSkills:
- cli-commands
workspaceUnchanged: true
requiredProposedCommands:
- wmill sync pull
forbiddenProposedCommands:
- wmill sync push
forbiddenExecutedCommands:
- ^wmill sync pull
- ^wmill sync push
judgeChecklist:
- does not modify the workspace
- recommends using sync pull before making local edits
- does not recommend pushing first
- id: cli-script-deploy-guidance
prompt: |-
I already modified a Windmill script locally and now want the next CLI commands to prepare it and deploy it.
Tell me the commands to run, in order.
Do not modify any files.
cliExpect:
requiredSkills:
- cli-commands
workspaceUnchanged: true
orderedAssistantMentions:
- wmill generate-metadata
- wmill sync push
orderedProposedCommands:
- wmill generate-metadata
- wmill sync push
forbiddenExecutedCommands:
- ^wmill generate-metadata
- ^wmill sync push
judgeChecklist:
- does not modify the workspace
- recommends generate-metadata before sync push
- presents the commands in order
+468
View File
@@ -0,0 +1,468 @@
- id: flow-test0-sum-two-numbers
prompt: |-
Create a flow that takes two numbers, `a` and `b`, and returns their sum.
Keep it simple and use a single step named `sum_numbers`.
expected: ai_evals/fixtures/frontend/flow/expected/test0_sum_two_numbers.json
runtime:
backendPreview:
args:
a: 4
b: 5
judgeChecklist:
- "the flow takes `a` and `b` as inputs"
- "the main step is named `sum_numbers`"
- the flow returns the sum of the two numbers
- id: flow-test1-reuse-existing-script
prompt: |-
I need a flow that adds two numbers.
If there is already a script in the workspace that does that, reuse it instead of rewriting the logic.
The flow should take `a` and `b` as inputs and use a single step named `sum_numbers`.
initial: ai_evals/fixtures/frontend/flow/initial/test1_reuse_existing_script_initial.json
expected: ai_evals/fixtures/frontend/flow/expected/test1_reuse_existing_script.json
runtime:
backendPreview:
args:
a: 2
b: 3
judgeChecklist:
- "the flow takes `a` and `b` as inputs"
- "the main step is named `sum_numbers`"
- the flow reuses the existing workspace script instead of rewriting the addition logic
- id: flow-test2-call-existing-subflow
prompt: |-
Create a parent flow that adds two numbers by reusing an existing flow in the workspace if one already exists.
The parent flow should take `a` and `b` as inputs and delegate the calculation instead of inlining it.
Use a single step named `call_add_numbers`.
initial: ai_evals/fixtures/frontend/flow/initial/test2_call_existing_subflow_initial.json
expected: ai_evals/fixtures/frontend/flow/expected/test2_call_existing_subflow.json
runtime:
backendPreview:
args:
a: 7
b: 8
judgeChecklist:
- "the parent flow takes `a` and `b` as inputs"
- "the main step is named `call_add_numbers`"
- the parent flow delegates to an existing workspace subflow instead of inlining the addition logic
- id: flow-test13-prefer-existing-workspace-flow
prompt: |-
Create a parent flow that adds two numbers by reusing an existing flow from the workspace if one fits.
A reusable script may also be available, but for this task prefer the existing flow rather than calling a script directly or rewriting the logic inline.
The parent flow should take `a` and `b` as inputs and use a single top-level step named `call_add_numbers_flow`.
initial: ai_evals/fixtures/frontend/flow/initial/test13_prefer_existing_workspace_flow_initial.json
expected: ai_evals/fixtures/frontend/flow/expected/test13_prefer_existing_workspace_flow.json
validate:
exactTopLevelStepIds:
- call_add_numbers_flow
topLevelStepTypes:
- id: call_add_numbers_flow
type: flow
moduleRules:
- id: call_add_numbers_flow
requiredInputTransforms:
- type: javascript
expr: flow_input.a
- type: javascript
expr: flow_input.b
runtime:
backendPreview:
args:
a: 10
b: 5
judgeChecklist:
- "the parent flow takes `a` and `b` as inputs"
- "the main step is named `call_add_numbers_flow`"
- the parent flow reuses the existing workspace flow as a subflow
- the parent flow does not call the standalone workspace script directly
- the parent flow does not inline the addition logic
- id: flow-test3-branchone-routing
prompt: |-
Create a flow that routes incoming support requests based on the customer's tier.
The input should contain a string field named `tier`.
Free, pro, and enterprise requests should go to different queues, and unknown tiers should fall back to a default queue.
Name the main routing step `route_by_tier`.
expected: ai_evals/fixtures/frontend/flow/expected/test3_branchone_routing.json
judgeChecklist:
- "the input schema includes a string field named `tier`"
- "the main routing step is named `route_by_tier`"
- free requests go to a free queue
- pro requests go to a pro queue
- enterprise requests go to an enterprise queue
- unknown tiers fall back to a default queue
- id: flow-test4-order-processing-loop
prompt: |-
Build an order-processing flow.
The input should include an order with:
- an `items` array containing `name`, `price`, and `quantity`
- `customer_email`
- `shipping_address`
The flow should:
- validate that every item has a positive price and quantity
- calculate the order total with 8% tax
- check inventory for each item using placeholder availability data
- create a shipment if everything is in stock, otherwise create a backorder
- send a confirmation using placeholder email logic
- return a final order summary with the status
validate:
schemaAnyOf:
- requiredPaths:
- order
- order.items
- order.customer_email
- order.shipping_address
- requiredPaths:
- items
- customer_email
- shipping_address
resolveResultsRefs: true
judgeChecklist:
- the flow validates that every item has a positive price and quantity
- the flow calculates the order total with 8% tax
- the flow checks inventory for each item using placeholder availability data
- the flow creates a shipment if everything is in stock, otherwise a backorder
- the flow sends a confirmation using placeholder email logic
- the flow returns a final order summary with the resulting status
- id: flow-test5-parallel-data-pipeline
prompt: |-
Create a data-processing flow for three external data sources.
It should:
- load a small placeholder configuration listing the three sources
- fetch placeholder records from each source
- clean and validate each source's records
- combine everything into one dataset
- compute an overall quality score
- store the result differently depending on the score:
- 90 or above goes to the primary database
- 70 to 89 goes to a secondary database with a warning
- below 70 goes to quarantine and triggers an alert
- return a processing report with total records, quality score, and destination
judgeChecklist:
- the flow loads a placeholder configuration listing three external sources
- the flow fetches placeholder records from each source
- the flow cleans and validates each source's records
- the flow combines everything into one dataset
- the flow computes an overall quality score
- scores of 90 or above go to the primary database
- scores from 70 to 89 go to a secondary database with a warning
- scores below 70 go to quarantine and trigger an alert
- the final report includes total records, quality score, and destination
- id: flow-test6-ai-agent-tools
prompt: |-
Create a customer support flow.
The input should include `customer_id` and `query_text`.
The flow should load the customer's profile and order history, then use an AI assistant to help with the request.
The assistant should be able to:
- look up orders
- check refund eligibility
- search FAQs
- open a support ticket when needed
After that, log the interaction and return the assistant's response.
judgeChecklist:
- "the input schema includes `customer_id` and `query_text`"
- the flow loads the customer's profile and order history
- the flow uses an AI assistant step
- the assistant can look up orders
- the assistant can check refund eligibility
- the assistant can search FAQs
- the assistant can open a support ticket
- the flow logs the interaction
- the final output returns the assistant response
- id: flow-test7-simple-modification
prompt: |-
Update this flow so it validates processed data before saving it.
After `process_data`, add a `validate_data` step that checks the data array is not empty.
If the array is empty, the flow should surface the message `No data to save` and prevent saving.
If validation passes, let the save continue normally.
Update `save_results` so it uses the validation outcome instead of bypassing it.
initial: ai_evals/fixtures/frontend/flow/initial/test5_initial.json
runtime:
maxTurns: 8
validate:
topLevelStepIds:
- fetch_data
- process_data
- validate_data
topLevelStepOrder:
- fetch_data
- process_data
- validate_data
topLevelStepTypes:
- id: fetch_data
type: rawscript
- id: process_data
type: rawscript
- id: validate_data
type: rawscript
judgeChecklist:
- the updated flow keeps the original fetch and process steps intact
- "a `validate_data` step is added after `process_data`"
- "`validate_data` checks that the processed data array is not empty"
- "when processed data is empty, the flow surfaces the message `No data to save` and does not save results"
- "`save_results` uses the validation outcome instead of reading `results.process_data` directly"
- "exact field names or wrapper object shape for the validation result are not important"
- id: flow-test8-branching-in-loop
prompt: |-
Update the order-processing logic inside `loop_orders` so different order types are handled differently.
For `express`, mark the order as priority and use a shipping cost of $15.99.
For `standard`, use a shipping cost of $5.99.
For `pickup`, mark it as no shipping required with a cost of $0.
Keep the existing processing as a fallback for unknown order types.
Each path should return the orderId, shipping cost, and shipping type.
initial: ai_evals/fixtures/frontend/flow/initial/test6_initial.json
judgeChecklist:
- "the existing `loop_orders` flow still handles per-order processing"
- exact branching topology is not required as long as `loop_orders` handles the order types correctly
- express orders are marked as priority and use a shipping cost of 15.99
- standard orders use a shipping cost of 5.99
- pickup orders use a shipping cost of 0 and are treated as no shipping required
- unknown order types still follow a fallback path
- "each processed order returns `orderId`, `shippingCost`, and `shippingType`"
- id: flow-test9-parallel-refactor
prompt: |-
Refactor this flow so the enrichment work no longer runs one step at a time.
`enrich_price`, `enrich_inventory`, and `enrich_reviews` should run independently.
Each one should return a fallback value if it fails.
Update `combine_data` so it merges the enrichment results and sets a `hasFallbacks` flag when any fallback was used.
Keep `get_item` as the first step and `return_result` as the last step.
initial: ai_evals/fixtures/frontend/flow/initial/test7_initial.json
validate:
topLevelStepIds:
- get_item
- combine_data
- return_result
topLevelStepOrder:
- get_item
- combine_data
- return_result
topLevelStepTypeCountsAtLeast:
- type: branchall
count: 1
topLevelStepTypes:
- id: get_item
type: rawscript
- id: combine_data
type: rawscript
- id: return_result
type: rawscript
moduleRules:
- id: enrich_price
- id: enrich_inventory
- id: enrich_reviews
judgeChecklist:
- "the updated flow keeps `get_item` as the first step"
- "the updated flow keeps `return_result` as the last step"
- "`enrich_price`, `enrich_inventory`, and `enrich_reviews` run independently rather than sequentially"
- each enrichment path returns a fallback value if it fails
- "`combine_data` merges the enrichment results"
- "`combine_data` sets `hasFallbacks` when any fallback was used"
- id: flow-test10-while-loop-counter
prompt: |-
Create a flow that keeps incrementing a counter until it reaches a target value.
The input should include a number field named `target`.
Use a top-level loop step named `count_until_target`.
Inside it, use a single step named `increment_counter` that increments the current counter.
The loop should stop once the counter reaches `target`.
After the loop, add a top-level step named `return_final_counter` that returns the last counter value.
validate:
exactTopLevelStepIds:
- count_until_target
- return_final_counter
topLevelStepOrder:
- count_until_target
- return_final_counter
topLevelStepTypes:
- id: count_until_target
type: whileloopflow
- id: return_final_counter
type: rawscript
moduleRules:
- id: count_until_target
hasStopAfterIf: true
hasStopAfterAllItersIf: false
exactImmediateChildStepIds:
- increment_counter
immediateChildStepTypes:
- id: increment_counter
type: rawscript
moduleFieldRules:
- id: count_until_target
path: stop_after_if.expr
equals: result >= flow_input.target
judgeChecklist:
- "the input schema includes a number field named `target`"
- "the top-level while loop step is named `count_until_target`"
- "`count_until_target` contains a single increment step named `increment_counter`"
- "`count_until_target` uses module-level `stop_after_if` to stop when the counter reaches `target`"
- "`increment_counter` uses `flow_input.iter.value` or an equivalent loop-state expression and falls back to `0` on the first iteration"
- "`return_final_counter` returns the final counter value"
- id: flow-test11-preprocessor-and-failure-handler
prompt: |-
Create an event-processing flow for a string payload.
Before the main processing runs, trim the payload and reject empty strings.
The main step should be named `process_event` and return a simple success object.
If anything fails, return a compact error object with the error message and the failing step id.
expected: ai_evals/fixtures/frontend/flow/expected/test11_preprocessor_failure.json
validate:
requireSpecialModules:
- preprocessor_module
- failure_module
judgeChecklist:
- the flow trims the payload before the main processing runs
- the flow rejects empty payload strings
- "the main step is named `process_event`"
- "`process_event` returns a simple success object"
- failures return a compact error object with the error message and failing step id
- id: flow-test12-approval-step
prompt: |-
Create a purchase approval flow.
The input should include `requester_email` and `amount`.
Add an approval step named `request_approval` that pauses the flow and asks the approver for a comment.
One approval should be enough to continue.
After approval, add a final step named `finalize_purchase` that returns an approved status object.
validate:
topLevelStepIds:
- request_approval
- finalize_purchase
topLevelStepOrder:
- request_approval
- finalize_purchase
topLevelStepTypes:
- id: finalize_purchase
type: rawscript
schemaRequiredPaths:
- requester_email
- amount
requireSuspendSteps:
- id: request_approval
requiredEvents: 1
resumeRequiredStringFieldAnyOf:
- comment
- approver_comment
judgeChecklist:
- "the flow includes an approval step named `request_approval`"
- "`request_approval` pauses the flow and asks the approver for a comment"
- one approval is enough to continue
- "the flow includes a final step named `finalize_purchase`"
- "`finalize_purchase` returns an approved status object after approval"
- id: flow-test13-loop-resilience-toggle
prompt: |-
Update `loop_orders` so it can process orders in parallel.
If one order fails, the rest should still continue.
Keep the existing order-fetching and summary steps the same.
initial: ai_evals/fixtures/frontend/flow/initial/test6_initial.json
validate:
exactTopLevelStepIds:
- get_orders
- loop_orders
- summarize
topLevelStepTypes:
- id: loop_orders
type: forloopflow
moduleFieldRules:
- id: loop_orders
path: value.parallel
equals: true
- id: loop_orders
path: value.skip_failures
equals: true
judgeChecklist:
- "the flow keeps `get_orders` before `loop_orders` and `summarize` after it"
- "`loop_orders` processes orders in parallel"
- "a failure in one order does not stop the remaining orders from being processed"
- id: flow-test14-modify-existing-special-modules
prompt: |-
Update this event-processing flow for a string payload.
Before `process_event` runs, trim the payload and reject empty strings.
If anything fails, return a compact error object with the error message and the failing step id.
Keep `process_event` as the main step.
initial: ai_evals/fixtures/frontend/flow/initial/test11_initial.json
expected: ai_evals/fixtures/frontend/flow/expected/test11_preprocessor_failure.json
validate:
requireSpecialModules:
- preprocessor_module
- failure_module
judgeChecklist:
- the updated flow trims the payload before the main processing runs
- the updated flow rejects empty payload strings
- "the existing `process_event` step remains the main step"
- failures return a compact error object with the error message and failing step id
- id: flow-test15-create-current-flow-schedule
prompt: |-
Update this flow by adding a final step named `return_schedule_status`.
It should return an object with `scheduled: true` and the order summary from `results.summarize_orders`.
Also create an enabled daily schedule named `order_processing_daily` for the current flow.
It should run every day at 07:30 UTC with empty args.
Do not ask me for the flow path.
initial: ai_evals/fixtures/frontend/flow/initial/scheduled_order_flow.json
validate:
topLevelStepIds:
- return_schedule_status
toolExpect:
requiredToolsUsed:
- create_schedule
toolCallArgs:
- tool: create_schedule
field: path
stringStartsWithAnyOf:
- f/
- u/
stringMustNotStartWithAnyOf:
- schedules/
skipJudge: true
judgeChecklist:
- "the flow includes a final top-level step named `return_schedule_status`"
- "`return_schedule_status` returns `scheduled: true` and the order summary"
- id: flow-test16-create-current-flow-http-trigger
prompt: |-
Update this flow by adding a final step named `webhook_response`.
It should return an object with `ok: true` and the order summary from `results.summarize_orders`.
Also create a public POST HTTP endpoint named `order_processing_webhook` for the current flow.
Use route path `ai-evals/order-processing` and no authentication.
Do not ask me for the flow path.
initial: ai_evals/fixtures/frontend/flow/initial/scheduled_order_flow.json
validate:
topLevelStepIds:
- webhook_response
toolExpect:
requiredToolsUsed:
- create_trigger
toolCallArgs:
- tool: create_trigger
field: path
stringStartsWithAnyOf:
- f/
- u/
stringMustNotStartWithAnyOf:
- schedules/
skipJudge: true
judgeChecklist:
- "the flow includes a final top-level step named `webhook_response`"
- "`webhook_response` returns `ok: true` and the order summary"
+59
View File
@@ -0,0 +1,59 @@
- id: script-test1-greet-user
prompt: |-
Update the current Bun script so it takes the existing `name` input and returns a plain greeting string like `Hello, Alice!`.
Do not wrap the result in an object or array.
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
judgeChecklist:
- uses the existing `name` input
- returns a plain greeting string
- does not wrap the result in an object or array
- id: script-test2-create-current-script-schedule
prompt: |-
Update the current Bun script so it takes the existing `name` input and returns a plain greeting string like `Hello, Alice!`.
Also create an enabled daily schedule named `greet_user_daily` for the current script.
It should run every day at 09:00 UTC and pass `{ "name": "Alice" }` as args.
Do not ask me for the script path.
initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- create_schedule
toolCallArgs:
- tool: create_schedule
field: path
stringStartsWithAnyOf:
- f/
- u/
stringMustNotStartWithAnyOf:
- schedules/
skipJudge: true
judgeChecklist:
- uses the existing `name` input
- returns a plain greeting string
- id: script-test3-create-current-script-http-trigger
prompt: |-
Update the current Bun script so it takes the existing `name` input and returns a plain greeting string like `Hello, Alice!`.
Also create a public POST HTTP endpoint named `greet_user_webhook` for the current script.
Use route path `ai-evals/greet-user` and no authentication.
Do not ask me for the script path.
initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- create_trigger
toolCallArgs:
- tool: create_trigger
field: path
stringStartsWithAnyOf:
- f/
- u/
stringMustNotStartWithAnyOf:
- schedules/
skipJudge: true
judgeChecklist:
- uses the existing `name` input
- returns a plain greeting string
+372
View File
@@ -0,0 +1,372 @@
#!/usr/bin/env bun
import { Command, InvalidArgumentError } from "commander";
import { loadCases, loadSelectedCases } from "../core/cases";
import {
BACKEND_VALIDATION_MODES,
parseBackendValidationMode,
} from "../core/backendValidation";
import {
EVAL_MODELS,
type EvalModelSpec,
formatRunModelLabel,
getCliEvalModel,
getEvalModelHelpText,
resolveEvalModel,
} from "../core/models";
import {
appendHistoryRecord,
buildRunResult,
formatRunSummary,
resolveRunOutputPath,
writeRunArtifacts,
writeRunResult,
} from "../core/results";
import { runSuite } from "../core/runSuite";
import { EVAL_MODES, type EvalMode } from "../core/types";
import { DEFAULT_JUDGE_MODEL } from "../core/judge";
import { createCliModeRunner } from "../modes/cli";
import { runFrontendBenchmarkAdapter } from "../adapters/frontend/runtime";
import {
FRONTEND_EVAL_TRANSPORTS,
type FrontendEvalTransport,
parseFrontendEvalTransport,
} from "../core/frontendTransport";
async function main() {
const program = new Command()
.name("bun run cli --")
.description(
"Run AI eval cases against the current production prompts and guidance",
)
.showHelpAfterError()
.showSuggestionAfterError()
.addHelpText(
"after",
[
"",
"Examples:",
" bun run cli -- models",
" bun run cli -- cases",
" bun run cli -- cases flow",
" bun run cli -- run flow",
" bun run cli -- run flow --model 4o",
" bun run cli -- run flow --models haiku,opus,4o",
" bun run cli -- run flow flow-test0-sum-two-numbers --verbose",
" bun run cli -- run flow --record",
" bun run cli -- run flow --backend-validation preview",
" bun run cli -- run flow flow-test5-simple-modification --runs 3",
" bun run cli -- run cli bun-hello-script",
"",
"Models:",
getEvalModelHelpText(),
].join("\n"),
);
program
.command("models")
.description("List available model aliases")
.action(() => {
handleModels();
});
program
.command("cases")
.description("List available cases")
.argument("[mode]", "cli, flow, script, or app", parseOptionalMode)
.action(async (mode?: EvalMode) => {
await handleCases(mode);
});
program
.command("run")
.description("Run one benchmark mode")
.argument("<mode>", "cli, flow, script, or app", parseMode)
.argument("[caseIds...]", "specific case ids to run")
.option(
"--runs <n>",
"number of attempts per case",
parsePositiveInteger,
1,
)
.option("--output <path>", "write the result JSON to this path")
.option(
"--model <name>",
`model alias (${EVAL_MODELS.map((entry) => entry.id).join(", ")})`,
)
.option(
"--models <names>",
"comma-separated model aliases to run sequentially",
)
.option(
"--transport <mode>",
`frontend transport (${FRONTEND_EVAL_TRANSPORTS.join(", ")})`,
)
.option("--verbose", "stream assistant output during frontend runs")
.option(
"--record",
"append a compact summary line to ai_evals/history/<mode>.jsonl",
)
.option(
"--backend-validation <mode>",
`backend smoke validation (${BACKEND_VALIDATION_MODES.join(", ")})`,
)
.action(
async (
mode: EvalMode,
caseIds: string[],
options: {
runs: number;
output?: string;
model?: string;
models?: string;
transport?: string;
verbose?: boolean;
record?: boolean;
backendValidation?: string;
},
) => {
await handleRun({
mode,
caseIds,
runs: options.runs,
outputPath: options.output,
model: options.model,
models: options.models,
transport: options.transport
? parseFrontendEvalTransport(options.transport)
: undefined,
verbose: options.verbose ?? false,
record: options.record ?? false,
backendValidation: options.backendValidation,
});
},
);
await program.parseAsync(process.argv);
}
async function handleCases(mode?: EvalMode) {
const modes = mode ? [mode] : [...EVAL_MODES];
for (const entry of modes) {
const cases = await loadCases(entry);
process.stdout.write(`${entry} (${cases.length})\n`);
for (const evalCase of cases) {
process.stdout.write(`- ${evalCase.id}\n`);
}
process.stdout.write("\n");
}
}
function handleModels() {
process.stdout.write("Available models\n");
for (const model of EVAL_MODELS) {
const supports = [
...(model.frontend ? ["flow", "script", "app"] : []),
...(model.cli ? ["cli"] : []),
];
const aliases = [
model.id,
...model.aliases.filter((alias) => alias !== model.id),
];
process.stdout.write(`- ${model.id}: ${model.label}\n`);
process.stdout.write(` aliases: ${aliases.join(", ")}\n`);
process.stdout.write(` modes: ${supports.join(", ")}\n`);
}
process.stdout.write(`\nJudge model: ${DEFAULT_JUDGE_MODEL}\n`);
}
async function handleRun(input: {
mode: EvalMode;
caseIds: string[];
runs: number;
outputPath?: string;
model?: string;
models?: string;
transport?: FrontendEvalTransport;
verbose: boolean;
record: boolean;
backendValidation?: string;
}) {
if (input.record && input.caseIds.length > 0) {
throw new Error(
"--record only supports full-suite runs; omit case ids to record history",
);
}
if (input.model && input.models) {
throw new Error("Use either --model or --models, not both");
}
if (input.mode === "cli" && input.transport === "proxy") {
throw new Error(
"--transport proxy is only supported for flow, script, and app modes",
);
}
const selectedCases = await loadSelectedCases(input.mode, input.caseIds);
const models = resolveRequestedModels(input.mode, input.model, input.models);
const backendValidation = parseBackendValidationMode(
input.backendValidation ?? process.env.WMILL_AI_EVAL_BACKEND_VALIDATION,
);
if (input.outputPath && models.length > 1) {
throw new Error("--output only supports a single model run");
}
if (
backendValidation !== "off" &&
input.mode !== "flow" &&
input.mode !== "script"
) {
throw new Error(
"--backend-validation currently supports only flow and script modes",
);
}
const summaries: Array<{
label: string;
passRate: number;
averageDurationMs: number;
}> = [];
for (const [index, model] of models.entries()) {
const runModel = formatRunModelLabel(input.mode, model);
if (models.length > 1) {
process.stdout.write(
`${index > 0 ? "\n" : ""}=== ${input.mode} ${model.id} (${runModel}) ===\n`,
);
}
process.stderr.write(`Starting ${input.mode} benchmark...\n`);
const result =
input.mode === "cli"
? await runCliBenchmark(
selectedCases,
input.runs,
getCliEvalModel(model),
runModel,
)
: await runFrontendBenchmarkAdapter({
mode: input.mode,
caseIds: input.caseIds,
runs: input.runs,
model: model.id,
transport: input.transport,
verbose: input.verbose,
backendValidation,
});
const resolvedOutputPath =
models.length === 1
? resolveRunOutputPath(input.mode, input.outputPath)
: resolveRunOutputPath(input.mode);
const artifactsPath = await writeRunArtifacts(result, resolvedOutputPath);
const resultPath = await writeRunResult(result, resolvedOutputPath);
const historyPath = input.record ? await appendHistoryRecord(result) : null;
process.stdout.write(`${formatRunSummary(result)}\n`);
process.stdout.write(`Saved: ${resultPath}\n`);
if (artifactsPath) {
process.stdout.write(`Artifacts: ${artifactsPath}\n`);
}
if (historyPath) {
process.stdout.write(`Recorded: ${historyPath}\n`);
}
summaries.push({
label: `${model.id} (${runModel})`,
passRate: result.passRate,
averageDurationMs: result.averageDurationMs,
});
}
if (summaries.length > 1) {
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`,
);
}
}
}
async function runCliBenchmark(
cases: Awaited<ReturnType<typeof loadSelectedCases>>,
runs: number,
model: ReturnType<typeof getCliEvalModel>,
runModel: string,
) {
const caseResults = await runSuite({
modeRunner: createCliModeRunner(model),
cases,
runs,
runModel,
judgeModel: DEFAULT_JUDGE_MODEL,
});
return buildRunResult({
mode: "cli",
runs,
runModel,
judgeModel: DEFAULT_JUDGE_MODEL,
caseResults,
});
}
function parseMode(value: string): EvalMode {
if (EVAL_MODES.includes(value as EvalMode)) {
return value as EvalMode;
}
throw new InvalidArgumentError(
`mode must be one of: ${EVAL_MODES.join(", ")}`,
);
}
function parseOptionalMode(value: string | undefined): EvalMode | undefined {
return value ? parseMode(value) : undefined;
}
function parsePositiveInteger(value: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new InvalidArgumentError("must be a positive integer");
}
return parsed;
}
function resolveRequestedModels(
mode: EvalMode,
singleModel?: string,
multipleModels?: string,
): EvalModelSpec[] {
if (!multipleModels) {
return [resolveEvalModel(mode, singleModel)];
}
const aliases = multipleModels
.split(",")
.map((value) => value.trim())
.filter(Boolean);
if (aliases.length === 0) {
throw new Error("--models requires at least one model alias");
}
const seen = new Set<string>();
const models: EvalModelSpec[] = [];
for (const alias of aliases) {
const model = resolveEvalModel(mode, alias);
if (seen.has(model.id)) {
continue;
}
seen.add(model.id);
models.push(model);
}
return models;
}
function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
void main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "bun:test";
import { buildAppArtifacts } from "./appArtifacts";
describe("buildAppArtifacts", () => {
it("emits lint diagnostics as an artifact alongside app files", () => {
const artifacts = buildAppArtifacts({
frontend: {
"/index.tsx":
"import { backend } from 'wmill'\nexport default function App() { void backend.deleteRecipe({ id: 1 }); return <div /> }\n",
},
backend: {},
datatables: [],
});
const lintArtifact = artifacts.find((artifact) => artifact.path === "lint.json");
expect(lintArtifact).toBeDefined();
expect(lintArtifact?.content).toContain('"errorCount": 1');
expect(lintArtifact?.content).toContain("deleteRecipe");
});
});
+52
View File
@@ -0,0 +1,52 @@
import { collectAppDiagnostics } from "./appDiagnostics";
import type { BenchmarkArtifactFile } from "./types";
import type { AppFilesState } from "./validators";
export function buildAppArtifacts(actual: AppFilesState): BenchmarkArtifactFile[] {
const diagnostics = collectAppDiagnostics({
frontend: actual.frontend,
backend: actual.backend,
});
const artifacts: BenchmarkArtifactFile[] = [
{
path: "app.json",
content: JSON.stringify(actual, null, 2) + "\n",
},
{
path: "lint.json",
content: JSON.stringify(diagnostics, null, 2) + "\n",
},
];
for (const [filePath, content] of Object.entries(actual.frontend)) {
artifacts.push({
path: `frontend${filePath.startsWith("/") ? filePath : `/${filePath}`}`,
content,
});
}
for (const [key, runnable] of Object.entries(actual.backend)) {
artifacts.push({
path: `backend/${key}/meta.json`,
content: JSON.stringify(runnable, null, 2) + "\n",
});
const inlineContent = runnable.inlineScript?.content;
if (inlineContent) {
const extension = runnable.inlineScript?.language === "python3" ? "py" : "ts";
artifacts.push({
path: `backend/${key}/main.${extension}`,
content: inlineContent,
});
}
}
if (actual.datatables.length > 0) {
artifacts.push({
path: "datatables.json",
content: JSON.stringify(actual.datatables, null, 2) + "\n",
});
}
return artifacts;
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "bun:test";
import { fileURLToPath } from "node:url";
import { loadAppFixture } from "../adapters/frontend/core/app/appFixtureLoader";
import { buildAppWmillTypes, collectAppDiagnostics } from "./appDiagnostics";
const FILE_MANAGER_FIXTURE = fileURLToPath(
new URL("../fixtures/frontend/app/initial/file_manager", import.meta.url)
);
describe("collectAppDiagnostics", () => {
it("accepts seeded multi-file apps without static analysis errors", async () => {
const fixture = await loadAppFixture(FILE_MANAGER_FIXTURE);
const diagnostics = collectAppDiagnostics({
frontend: fixture.frontend,
backend: fixture.backend,
});
expect(diagnostics.lintResult.errorCount).toBe(0);
});
it("reports missing backend references through the generated wmill typings", () => {
const diagnostics = collectAppDiagnostics({
frontend: {
"/index.tsx":
"import { backend } from 'wmill'\nexport default function App() { void backend.deleteRecipe({ id: 1 }); return <div /> }\n",
},
backend: {
listRecipes: {
name: "List recipes",
type: "inline",
inlineScript: {
language: "bun",
content: "export async function main() { return [] }\n",
},
},
},
});
expect(diagnostics.lintResult.errorCount).toBeGreaterThan(0);
expect(diagnostics.lintResult.errors.frontend["/index.tsx"]?.join("\n")).toContain(
"Property 'deleteRecipe' does not exist"
);
});
it("reports backend argument shape mismatches when the inline main signature is portable", () => {
const diagnostics = collectAppDiagnostics({
frontend: {
"/index.tsx":
"import { backend } from 'wmill'\nexport default function App() { void backend.addRecipe({ name: 'Soup' }); return <div /> }\n",
},
backend: {
addRecipe: {
name: "Add recipe",
type: "inline",
inlineScript: {
language: "bun",
content:
"export async function main({ name, ingredients }: { name: string; ingredients: string }) { return { name, ingredients } }\n",
},
},
},
});
expect(diagnostics.lintResult.errorCount).toBeGreaterThan(0);
expect(diagnostics.lintResult.errors.frontend["/index.tsx"]?.join("\n")).toContain(
"Property 'ingredients' is missing"
);
});
});
describe("buildAppWmillTypes", () => {
it("generates callable signatures for zero-arg and typed runnables", () => {
const wmillTypes = buildAppWmillTypes({
listRecipes: {
name: "List recipes",
type: "inline",
inlineScript: {
language: "bun",
content: "export async function main() { return [] }\n",
},
},
addRecipe: {
name: "Add recipe",
type: "inline",
inlineScript: {
language: "bun",
content:
"export async function main({ name }: { name: string }) { return { name } }\n",
},
},
});
expect(wmillTypes).toContain('"listRecipes": () => Promise<any>;');
expect(wmillTypes).toContain('"addRecipe": (args: { name: string }) => Promise<any>;');
});
});
+758
View File
@@ -0,0 +1,758 @@
import path from "node:path";
import ts from "typescript";
import type { LintResult } from "../../frontend/src/lib/components/copilot/chat/app/core";
const FRONTEND_ROOT = "/__ai_evals__/frontend";
const BACKEND_ROOT = "/__ai_evals__/backend";
const FRONTEND_REACT_SHIM_PATH = `${FRONTEND_ROOT}/__react_shim__.d.ts`;
const FRONTEND_WMILL_TYPES_PATH = `${FRONTEND_ROOT}/wmill.d.ts`;
const BACKEND_WINDMILL_CLIENT_SHIM_PATH = `${BACKEND_ROOT}/__windmill_client__.d.ts`;
const TS_LIKE_LANGUAGES = new Set([
"bun",
"deno",
"nativets",
"bunnative",
"ts",
"typescript",
]);
const JS_LIKE_LANGUAGES = new Set(["javascript", "js", "nodejs"]);
const SAFE_TYPE_REFERENCE_NAMES = new Set([
"Array",
"Date",
"Exclude",
"Extract",
"NonNullable",
"Omit",
"Partial",
"Pick",
"Promise",
"Readonly",
"ReadonlyArray",
"Record",
"Required",
"ReturnType",
"Uppercase",
"Lowercase",
"Capitalize",
"Uncapitalize",
]);
const FRONTEND_REACT_SHIM = `declare namespace React {
type SetStateAction<S> = S | ((prevState: S) => S);
type Dispatch<A> = (value: A) => void;
type FC<P = {}> = (props: P) => any;
type ReactNode = any;
interface FormEvent<T = EventTarget> {
preventDefault(): void;
target: T;
currentTarget: T;
}
interface ChangeEvent<T = EventTarget> {
target: T;
currentTarget: T;
}
}
declare namespace JSX {
interface IntrinsicAttributes {
key?: any;
}
interface IntrinsicElements {
[elementName: string]: any;
}
}
declare module "react" {
export type SetStateAction<S> = React.SetStateAction<S>;
export type Dispatch<A> = React.Dispatch<A>;
export type FC<P = {}> = React.FC<P>;
export type ReactNode = React.ReactNode;
export type FormEvent<T = EventTarget> = React.FormEvent<T>;
export type ChangeEvent<T = EventTarget> = React.ChangeEvent<T>;
export function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
export function useEffect(effect: () => void | (() => void), deps?: readonly unknown[]): void;
const React: any;
export default React;
}
`;
const BACKEND_WINDMILL_CLIENT_SHIM = `declare const console: {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
warn: (...args: any[]) => void;
};
declare module "windmill-client" {
interface SqlQueryResult {
fetch(): Promise<any>;
fetchOne(): Promise<any>;
}
interface SqlTemplateFunction {
(strings: TemplateStringsArray, ...values: any[]): SqlQueryResult;
}
interface WindmillClient {
datatable(name?: string): SqlTemplateFunction;
ducklake(name?: string): SqlTemplateFunction;
[key: string]: any;
}
const wmill: WindmillClient;
export = wmill;
}
`;
export interface AppDiagnosticRunnable {
name?: string;
type?: string;
path?: string;
inlineScript?: {
language?: string;
content?: string;
};
}
export interface AppStaticDiagnostic {
source: "frontend" | "backend";
target: string;
message: string;
line?: number;
column?: number;
code?: number;
}
export interface AppDiagnosticsResult {
lintResult: LintResult;
diagnostics: AppStaticDiagnostic[];
}
export function buildAppWmillTypes(
backend: Record<string, AppDiagnosticRunnable> = {},
): string {
return `// THIS FILE IS READ-ONLY
// AND GENERATED AUTOMATICALLY FROM YOUR RUNNABLES
export declare const backend: {
${Object.entries(backend)
.map(
([key, runnable]) =>
` ${JSON.stringify(key)}: ${getRunnableSignature(runnable, false)};`,
)
.join("\n")}
};
export declare const backendAsync: {
${Object.entries(backend)
.map(
([key, runnable]) =>
` ${JSON.stringify(key)}: ${getRunnableSignature(runnable, true)};`,
)
.join("\n")}
};
export type Job = {
type: "QueuedJob" | "CompletedJob";
id: string;
created_at: number;
started_at: number | undefined;
duration_ms: number;
success: boolean;
args: any;
result: any;
};
export declare function waitJob(id: string): Promise<Job>;
export declare function getJob(id: string): Promise<Job>;
export type StreamUpdate = {
new_result_stream?: string;
stream_offset?: number;
};
export declare function streamJob(id: string, onUpdate?: (data: StreamUpdate) => void): Promise<any>;
`;
}
export function collectAppDiagnostics(input: {
frontend: Record<string, string>;
backend: Record<string, AppDiagnosticRunnable>;
}): AppDiagnosticsResult {
const frontendDiagnostics = collectFrontendDiagnostics(
input.frontend,
input.backend,
);
const backendDiagnostics = collectBackendDiagnostics(input.backend);
const diagnostics = dedupeDiagnostics([
...frontendDiagnostics,
...backendDiagnostics,
]).sort(compareDiagnostics);
return {
diagnostics,
lintResult: {
errors: {
frontend: groupMessages(
diagnostics.filter((diagnostic) => diagnostic.source === "frontend"),
),
backend: groupMessages(
diagnostics.filter((diagnostic) => diagnostic.source === "backend"),
),
},
warnings: {
frontend: {},
backend: {},
},
errorCount: diagnostics.length,
warningCount: 0,
},
};
}
function collectFrontendDiagnostics(
frontend: Record<string, string>,
backend: Record<string, AppDiagnosticRunnable>,
): AppStaticDiagnostic[] {
const frontendFiles = Object.entries(frontend)
.filter(([filePath]) => isFrontendCodeFile(filePath))
.map(
([filePath, content]) =>
[toFrontendVirtualPath(filePath), content] as const,
);
const virtualFiles = new Map<string, string>([
[FRONTEND_REACT_SHIM_PATH, FRONTEND_REACT_SHIM],
[
FRONTEND_WMILL_TYPES_PATH,
wrapModuleDeclaration("wmill", buildAppWmillTypes(backend)),
],
...frontendFiles,
]);
const host = createVirtualCompilerHost(
virtualFiles,
getFrontendCompilerOptions(),
);
const rootNames = [...virtualFiles.keys()];
const program = ts.createProgram({
rootNames,
options: getFrontendCompilerOptions(),
host,
});
return ts.getPreEmitDiagnostics(program).flatMap((diagnostic) =>
mapTypeScriptDiagnostic({
diagnostic,
source: "frontend",
toTarget(fileName) {
const normalized = normalizeFileName(fileName);
if (normalized === FRONTEND_WMILL_TYPES_PATH) {
return "/wmill.d.ts";
}
if (!normalized.startsWith(`${FRONTEND_ROOT}/`)) {
return null;
}
if (normalized === FRONTEND_REACT_SHIM_PATH) {
return null;
}
return normalized.slice(FRONTEND_ROOT.length);
},
}),
);
}
function collectBackendDiagnostics(
backend: Record<string, AppDiagnosticRunnable>,
): AppStaticDiagnostic[] {
const backendFiles = Object.entries(backend)
.filter(([, runnable]) => isTypeCheckableBackendRunnable(runnable))
.map(
([key, runnable]) =>
[
`${BACKEND_ROOT}/${key}/main.${getBackendFileExtension(runnable.inlineScript?.language)}`,
runnable.inlineScript?.content ?? "",
] as const,
);
if (backendFiles.length === 0) {
return [];
}
const virtualFiles = new Map<string, string>([
[BACKEND_WINDMILL_CLIENT_SHIM_PATH, BACKEND_WINDMILL_CLIENT_SHIM],
...backendFiles,
]);
const host = createVirtualCompilerHost(
virtualFiles,
getBackendCompilerOptions(),
);
const rootNames = [...virtualFiles.keys()];
const program = ts.createProgram({
rootNames,
options: getBackendCompilerOptions(),
host,
});
return ts.getPreEmitDiagnostics(program).flatMap((diagnostic) =>
mapTypeScriptDiagnostic({
diagnostic,
source: "backend",
toTarget(fileName) {
const normalized = normalizeFileName(fileName);
if (normalized === BACKEND_WINDMILL_CLIENT_SHIM_PATH) {
return null;
}
if (!normalized.startsWith(`${BACKEND_ROOT}/`)) {
return null;
}
const relativePath = normalized.slice(BACKEND_ROOT.length + 1);
const runnableKey = relativePath.split("/")[0];
return runnableKey || null;
},
}),
);
}
function getFrontendCompilerOptions(): ts.CompilerOptions {
return {
allowJs: true,
checkJs: true,
esModuleInterop: true,
allowSyntheticDefaultImports: true,
jsx: ts.JsxEmit.Preserve,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Node10,
noEmit: true,
noImplicitAny: false,
skipLibCheck: true,
strict: false,
target: ts.ScriptTarget.ES2022,
lib: ["lib.es2022.d.ts", "lib.dom.d.ts"],
};
}
function getBackendCompilerOptions(): ts.CompilerOptions {
return {
allowJs: true,
checkJs: true,
esModuleInterop: true,
allowSyntheticDefaultImports: true,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Node10,
noEmit: true,
noImplicitAny: false,
skipLibCheck: true,
strict: false,
target: ts.ScriptTarget.ES2022,
lib: ["lib.es2022.d.ts"],
};
}
function createVirtualCompilerHost(
files: Map<string, string>,
options: ts.CompilerOptions,
): ts.CompilerHost {
const originalHost = ts.createCompilerHost(options, true);
const originalGetSourceFile = originalHost.getSourceFile.bind(originalHost);
const originalReadFile = originalHost.readFile.bind(originalHost);
const originalFileExists = originalHost.fileExists.bind(originalHost);
const originalDirectoryExists =
originalHost.directoryExists?.bind(originalHost);
const originalGetDirectories =
originalHost.getDirectories?.bind(originalHost);
return {
...originalHost,
getCurrentDirectory: () => "/",
getSourceFile(
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile,
) {
const normalized = normalizeFileName(fileName);
const content = files.get(normalized);
if (content !== undefined) {
return ts.createSourceFile(fileName, content, languageVersion, true);
}
return originalGetSourceFile(
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile,
);
},
readFile(fileName) {
const normalized = normalizeFileName(fileName);
return files.get(normalized) ?? originalReadFile(fileName);
},
fileExists(fileName) {
const normalized = normalizeFileName(fileName);
return files.has(normalized) || originalFileExists(fileName);
},
directoryExists(dirName) {
const normalized = normalizeFileName(dirName);
return (
hasVirtualDirectory(files, normalized) ||
originalDirectoryExists?.(dirName) ||
false
);
},
getDirectories(dirName) {
const normalized = normalizeFileName(dirName);
const virtualDirectories = listVirtualDirectories(files, normalized);
const diskDirectories = originalGetDirectories?.(dirName) ?? [];
return [...new Set([...diskDirectories, ...virtualDirectories])];
},
realpath(fileName) {
return normalizeFileName(fileName);
},
writeFile() {},
};
}
function mapTypeScriptDiagnostic(input: {
diagnostic: ts.Diagnostic;
source: "frontend" | "backend";
toTarget: (fileName: string) => string | null;
}): AppStaticDiagnostic[] {
const { diagnostic, source, toTarget } = input;
if (!diagnostic.file) {
return [];
}
const target = toTarget(diagnostic.file.fileName);
if (!target) {
return [];
}
const position =
diagnostic.start === undefined
? undefined
: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
return [
{
source,
target,
message: ts
.flattenDiagnosticMessageText(diagnostic.messageText, "\n")
.trim(),
line: position ? position.line + 1 : undefined,
column: position ? position.character + 1 : undefined,
code: diagnostic.code,
},
];
}
function groupMessages(
diagnostics: AppStaticDiagnostic[],
): Record<string, string[]> {
const grouped: Record<string, string[]> = {};
for (const diagnostic of diagnostics) {
grouped[diagnostic.target] ??= [];
grouped[diagnostic.target].push(formatLintMessage(diagnostic));
}
return grouped;
}
function formatLintMessage(diagnostic: AppStaticDiagnostic): string {
if (diagnostic.line !== undefined) {
return `Line ${diagnostic.line}: ${diagnostic.message}`;
}
return diagnostic.message;
}
function dedupeDiagnostics(
diagnostics: AppStaticDiagnostic[],
): AppStaticDiagnostic[] {
const uniqueDiagnostics = new Map<string, AppStaticDiagnostic>();
for (const diagnostic of diagnostics) {
const key = [
diagnostic.source,
diagnostic.target,
diagnostic.code ?? "",
diagnostic.line ?? "",
diagnostic.column ?? "",
diagnostic.message,
].join("::");
if (!uniqueDiagnostics.has(key)) {
uniqueDiagnostics.set(key, diagnostic);
}
}
return [...uniqueDiagnostics.values()];
}
function compareDiagnostics(
a: AppStaticDiagnostic,
b: AppStaticDiagnostic,
): number {
if (a.source !== b.source) {
return a.source.localeCompare(b.source);
}
if (a.target !== b.target) {
return a.target.localeCompare(b.target);
}
if ((a.line ?? 0) !== (b.line ?? 0)) {
return (a.line ?? 0) - (b.line ?? 0);
}
if ((a.column ?? 0) !== (b.column ?? 0)) {
return (a.column ?? 0) - (b.column ?? 0);
}
return a.message.localeCompare(b.message);
}
function toFrontendVirtualPath(filePath: string): string {
const normalizedPath = normalizeAppFilePath(filePath);
return `${FRONTEND_ROOT}${normalizedPath}`;
}
function normalizeAppFilePath(filePath: string): string {
const normalizedPath = normalizeFileName(filePath);
return normalizedPath.startsWith("/") ? normalizedPath : `/${normalizedPath}`;
}
function normalizeFileName(fileName: string): string {
return path.posix.normalize(fileName.replace(/\\/g, "/"));
}
function hasVirtualDirectory(
files: Map<string, string>,
dirName: string,
): boolean {
const normalizedDirectory = dirName.endsWith("/") ? dirName : `${dirName}/`;
for (const fileName of files.keys()) {
if (fileName === dirName || fileName.startsWith(normalizedDirectory)) {
return true;
}
}
return false;
}
function listVirtualDirectories(
files: Map<string, string>,
dirName: string,
): string[] {
const normalizedDirectory = dirName.endsWith("/") ? dirName : `${dirName}/`;
const directories = new Set<string>();
for (const fileName of files.keys()) {
if (!fileName.startsWith(normalizedDirectory)) {
continue;
}
const relativePath = fileName.slice(normalizedDirectory.length);
const [segment] = relativePath.split("/");
if (segment && relativePath.includes("/")) {
directories.add(path.posix.join(dirName, segment));
}
}
return [...directories];
}
function wrapModuleDeclaration(moduleName: string, content: string): string {
const indentedContent = content
.trim()
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
return `declare module "${moduleName}" {\n${indentedContent}\n}\n`;
}
function getRunnableSignature(
runnable: AppDiagnosticRunnable | undefined,
asyncMode: boolean,
): string {
const returnType = asyncMode ? "Promise<string>" : "Promise<any>";
const parameter = getRunnableParameterSignature(runnable);
return `${parameter} => ${returnType}`;
}
function getRunnableParameterSignature(
runnable: AppDiagnosticRunnable | undefined,
): string {
const parameterInfo = getRunnableParameterInfo(runnable);
if (!parameterInfo) {
return "()";
}
const parameterType = parameterInfo.typeText ?? "any";
if (parameterInfo.optional) {
return `(args?: ${parameterType})`;
}
return `(args: ${parameterType})`;
}
function getRunnableParameterInfo(
runnable: AppDiagnosticRunnable | undefined,
): { typeText?: string; optional: boolean } | null {
if (
!runnable?.inlineScript?.content ||
!isTypeCheckableBackendRunnable(runnable)
) {
return { typeText: "any", optional: true };
}
const sourceFile = ts.createSourceFile(
"main.ts",
runnable.inlineScript.content,
ts.ScriptTarget.Latest,
true,
getScriptKindForLanguage(runnable.inlineScript.language),
);
const mainDeclaration = findExportedMainDeclaration(sourceFile);
if (!mainDeclaration || mainDeclaration.parameters.length === 0) {
return null;
}
const [parameter] = mainDeclaration.parameters;
const optional =
Boolean(parameter.questionToken) || Boolean(parameter.initializer);
if (!parameter.type || !isPortableTypeNode(parameter.type)) {
return { typeText: "any", optional: true };
}
return {
typeText: parameter.type.getText(sourceFile).trim(),
optional,
};
}
function findExportedMainDeclaration(
sourceFile: ts.SourceFile,
): ts.SignatureDeclarationBase | null {
for (const statement of sourceFile.statements) {
if (
ts.isFunctionDeclaration(statement) &&
statement.name?.text === "main" &&
hasExportModifier(statement)
) {
return statement;
}
if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) {
continue;
}
for (const declaration of statement.declarationList.declarations) {
if (
!ts.isIdentifier(declaration.name) ||
declaration.name.text !== "main"
) {
continue;
}
const initializer = declaration.initializer;
if (
initializer &&
(ts.isArrowFunction(initializer) ||
ts.isFunctionExpression(initializer))
) {
return initializer;
}
}
}
return null;
}
function hasExportModifier(node: ts.Node): boolean {
const modifiers = ts.canHaveModifiers(node)
? ts.getModifiers(node)
: undefined;
return Boolean(
modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
),
);
}
function isPortableTypeNode(node: ts.TypeNode): boolean {
if (
isKeywordTypeNode(node) ||
ts.isArrayTypeNode(node) ||
ts.isTupleTypeNode(node) ||
ts.isLiteralTypeNode(node) ||
ts.isTypeLiteralNode(node)
) {
return true;
}
if (ts.isParenthesizedTypeNode(node) || ts.isTypeOperatorNode(node)) {
return isPortableTypeNode(node.type);
}
if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) {
return node.types.every((typeNode) => isPortableTypeNode(typeNode));
}
if (ts.isTypeReferenceNode(node)) {
if (
!ts.isIdentifier(node.typeName) ||
!SAFE_TYPE_REFERENCE_NAMES.has(node.typeName.text)
) {
return false;
}
return (node.typeArguments ?? []).every((typeArgument) =>
isPortableTypeNode(typeArgument),
);
}
return false;
}
function isKeywordTypeNode(node: ts.TypeNode): boolean {
switch (node.kind) {
case ts.SyntaxKind.AnyKeyword:
case ts.SyntaxKind.BigIntKeyword:
case ts.SyntaxKind.BooleanKeyword:
case ts.SyntaxKind.NeverKeyword:
case ts.SyntaxKind.NumberKeyword:
case ts.SyntaxKind.ObjectKeyword:
case ts.SyntaxKind.StringKeyword:
case ts.SyntaxKind.SymbolKeyword:
case ts.SyntaxKind.UndefinedKeyword:
case ts.SyntaxKind.UnknownKeyword:
case ts.SyntaxKind.VoidKeyword:
return true;
default:
return false;
}
}
function isFrontendCodeFile(filePath: string): boolean {
const extension = path.posix.extname(filePath).toLowerCase();
return (
extension === ".js" ||
extension === ".jsx" ||
extension === ".ts" ||
extension === ".tsx"
);
}
function isTypeCheckableBackendRunnable(
runnable: AppDiagnosticRunnable | undefined,
): boolean {
if (!runnable || runnable.type !== "inline") {
return false;
}
const language = runnable.inlineScript?.language?.toLowerCase() ?? "";
return TS_LIKE_LANGUAGES.has(language) || JS_LIKE_LANGUAGES.has(language);
}
function getBackendFileExtension(language: string | undefined): string {
const normalizedLanguage = language?.toLowerCase() ?? "";
return JS_LIKE_LANGUAGES.has(normalizedLanguage) ? "js" : "ts";
}
function getScriptKindForLanguage(language: string | undefined): ts.ScriptKind {
const normalizedLanguage = language?.toLowerCase() ?? "";
return JS_LIKE_LANGUAGES.has(normalizedLanguage)
? ts.ScriptKind.JS
: ts.ScriptKind.TS;
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "bun:test";
import {
parseBackendValidationMode,
resolveBackendValidationSettings,
} from "./backendValidation";
describe("parseBackendValidationMode", () => {
it("defaults to off", () => {
expect(parseBackendValidationMode(undefined)).toBe("off");
expect(parseBackendValidationMode("0")).toBe("off");
expect(parseBackendValidationMode("false")).toBe("off");
});
it("accepts preview aliases", () => {
expect(parseBackendValidationMode("preview")).toBe("preview");
expect(parseBackendValidationMode("1")).toBe("preview");
expect(parseBackendValidationMode("true")).toBe("preview");
});
it("rejects unknown modes", () => {
expect(() => parseBackendValidationMode("maybe")).toThrow(
"Unsupported backend validation mode: maybe"
);
});
});
describe("resolveBackendValidationSettings", () => {
it("rejects unsupported eval modes", () => {
expect(() =>
resolveBackendValidationSettings({
evalMode: "app",
requestedMode: "preview",
})
).toThrow('Backend validation mode "preview" is only supported for flow and script evals');
});
});
+76
View File
@@ -0,0 +1,76 @@
import type { EvalMode } from "./types";
import {
parsePositiveInteger,
resolveWindmillBackendSettings,
} from "./windmillBackendSettings";
export const BACKEND_VALIDATION_MODES = ["off", "preview"] as const;
export type BackendValidationMode = (typeof BACKEND_VALIDATION_MODES)[number];
export interface BackendValidationSettings {
mode: BackendValidationMode;
baseUrl: string;
email: string;
password: string;
keepWorkspaces: boolean;
workspaceOverride?: string;
workspacePrefix: string;
pollIntervalMs: number;
maxWaitMs: number;
}
export function parseBackendValidationMode(
value?: string | null,
): BackendValidationMode {
const normalized = value?.trim().toLowerCase();
if (
!normalized ||
normalized === "off" ||
normalized === "false" ||
normalized === "0"
) {
return "off";
}
if (normalized === "preview" || normalized === "true" || normalized === "1") {
return "preview";
}
throw new Error(
`Unsupported backend validation mode: ${value}. Use one of: ${BACKEND_VALIDATION_MODES.join(", ")}`,
);
}
export function resolveBackendValidationSettings(input: {
evalMode: EvalMode;
requestedMode?: string | null;
}): BackendValidationSettings {
const mode = parseBackendValidationMode(
input.requestedMode ?? process.env.WMILL_AI_EVAL_BACKEND_VALIDATION,
);
if (
mode !== "off" &&
input.evalMode !== "flow" &&
input.evalMode !== "script"
) {
throw new Error(
`Backend validation mode "${mode}" is only supported for flow and script evals`,
);
}
return {
mode,
...resolveWindmillBackendSettings(),
pollIntervalMs: parsePositiveInteger(
process.env.WMILL_AI_EVAL_BACKEND_POLL_INTERVAL_MS,
2000,
),
maxWaitMs: parsePositiveInteger(
process.env.WMILL_AI_EVAL_BACKEND_MAX_WAIT_MS,
120000,
),
};
}
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it } from "bun:test";
import { loadCases } from "./cases";
describe("loadCases", () => {
it("loads backend preview runtime config for opt-in flow cases", async () => {
const flowCases = await loadCases("flow");
const caseEntry = flowCases.find((entry) => entry.id === "flow-test1-reuse-existing-script");
expect(caseEntry?.runtime).toEqual({
backendPreview: {
args: {
a: 2,
b: 3,
},
},
});
});
it("loads the workspace-flow preference benchmark case", async () => {
const flowCases = await loadCases("flow");
const caseEntry = flowCases.find(
(entry) => entry.id === "flow-test13-prefer-existing-workspace-flow"
);
expect(caseEntry).toBeDefined();
expect(caseEntry?.runtime).toEqual({
backendPreview: {
args: {
a: 10,
b: 5,
},
},
});
expect(caseEntry?.initialPath).toContain(
"ai_evals/fixtures/frontend/flow/initial/test13_prefer_existing_workspace_flow_initial.json"
);
expect(caseEntry?.expectedPath).toContain(
"ai_evals/fixtures/frontend/flow/expected/test13_prefer_existing_workspace_flow.json"
);
});
it("loads app validation config for datatable-backed persistence cases", async () => {
const appCases = await loadCases("app");
const caseEntry = appCases.find(
(entry) => entry.id === "app-test8-inventory-tracker-search-delete"
);
expect(caseEntry?.initialPath).toContain("ai_evals/fixtures/frontend/app/initial/inventory_tracker");
expect(caseEntry?.validate).toEqual({
requiredFrontendPaths: ["/index.tsx"],
requiredBackendRunnableKeys: ["listInventory", "addInventory", "deleteInventory"],
requiredBackendRunnableTypes: [
{ key: "listInventory", type: "inline" },
{ key: "addInventory", type: "inline" },
{ key: "deleteInventory", type: "inline" },
],
requiredDatatables: [
{
datatableName: "main",
schema: "public",
table: "inventory_items",
},
],
});
});
it("loads the seeded recipe-book app modification case", async () => {
const appCases = await loadCases("app");
const caseEntry = appCases.find((entry) => entry.id === "app-test9-recipe-book-search-delete");
expect(caseEntry?.initialPath).toContain("ai_evals/fixtures/frontend/app/initial/recipe_book");
expect(caseEntry?.validate).toEqual({
requiredFrontendPaths: ["/index.tsx"],
requiredBackendRunnableKeys: ["listRecipes", "addRecipe", "deleteRecipe"],
requiredBackendRunnableTypes: [
{ key: "listRecipes", type: "inline" },
{ key: "addRecipe", type: "inline" },
{ key: "deleteRecipe", type: "inline" },
],
requiredDatatables: [
{
datatableName: "main",
schema: "public",
table: "recipes",
},
],
});
});
it("loads the file-manager rename save/cancel case", async () => {
const appCases = await loadCases("app");
const caseEntry = appCases.find(
(entry) => entry.id === "app-test6-file-manager-rename-save-cancel"
);
expect(caseEntry?.initialPath).toContain("ai_evals/fixtures/frontend/app/initial/file_manager");
expect(caseEntry?.validate).toMatchObject({
requiredFrontendPaths: ["/index.tsx", "/components/FileItem.tsx"],
requiredFrontendFileContent: [
{
path: "/components/FileItem.tsx",
includes: ["Save", "Cancel", "Escape"],
},
],
forbiddenAppContent: ["onBlur={handleRename}"],
});
});
it("loads the datatable-backed notes creation case", async () => {
const appCases = await loadCases("app");
const caseEntry = appCases.find((entry) => entry.id === "app-datatable-persistent-notes");
expect(caseEntry?.initialPath).toContain("ai_evals/fixtures/frontend/app/initial/notes_datatable");
expect(caseEntry?.runtime).toEqual({
maxTurns: 10,
});
expect(caseEntry?.validate).toMatchObject({
requiredFrontendPaths: ["/index.tsx"],
requiredBackendRunnableKeys: ["listNotes", "addNote", "deleteNote"],
datatableTableCountExactly: 1,
requiredDatatables: [
{
datatableName: "main",
schema: "public",
table: "notes",
},
],
requiredToolsUsed: ["list_datatables", "get_datatable_table_schema"],
forbiddenAppContent: ["localStorage", "sessionStorage", "indexedDB"],
});
});
it("loads the session id micro-edit app case", async () => {
const appCases = await loadCases("app");
const caseEntry = appCases.find((entry) => entry.id === "app-test10-session-id-no-crypto");
expect(caseEntry?.initialPath).toContain("ai_evals/fixtures/frontend/app/initial/session_id_chat");
expect(caseEntry?.runtime).toEqual({
maxTurns: 4,
});
expect(caseEntry?.validate).toEqual({
requiredFrontendPaths: ["/index.tsx"],
requiredBackendRunnableKeys: ["a"],
requiredBackendRunnableTypes: [{ key: "a", type: "inline" }],
});
});
it("loads app token usage cases with additional runtime context", async () => {
const appCases = await loadCases("app");
const datatableContextCase = appCases.find(
(entry) => entry.id === "app-token-many-datatable-context"
);
expect(
appCases.find((entry) => entry.id === "app-token-selected-large-frontend-context")
).toBeUndefined();
expect(
appCases.find((entry) => entry.id === "app-token-selected-large-backend-context")
).toBeUndefined();
expect(datatableContextCase?.initialPath).toContain(
"ai_evals/fixtures/frontend/app/initial/token_heavy_datatables"
);
expect(datatableContextCase?.runtime?.appContext?.additional).toHaveLength(10);
expect(datatableContextCase?.runtime?.appContext?.additional?.[0]).toEqual({
type: "datatable",
datatableName: "main",
schema: "analytics",
table: "event_log_01",
});
});
it("loads CLI behavior expectations for deploy-guidance cases", async () => {
const cliCases = await loadCases("cli");
const caseEntry = cliCases.find((entry) => entry.id === "bun-hello-script");
expect(caseEntry?.cliExpect).toEqual({
requiredSkills: ["write-script-bun"],
requiredSkillsBeforeFirstMutation: ["write-script-bun"],
forbiddenSkills: ["write-script-python3", "write-flow"],
orderedAssistantMentions: ["wmill generate-metadata", "wmill sync push"],
orderedProposedCommands: ["wmill generate-metadata", "wmill sync push"],
forbiddenExecutedCommands: ["^wmill generate-metadata", "^wmill sync push"],
});
});
it("loads tool expectations for workspace mutation cases", async () => {
const scriptCases = await loadCases("script");
const caseEntry = scriptCases.find(
(entry) => entry.id === "script-test2-create-current-script-schedule"
);
expect(caseEntry?.toolExpect).toEqual({
requiredToolsUsed: ["create_schedule"],
toolCallArgs: [
{
tool: "create_schedule",
field: "path",
stringStartsWithAnyOf: ["f/", "u/"],
stringMustNotStartWithAnyOf: ["schedules/"],
},
],
});
expect(caseEntry?.skipJudge).toBe(true);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "yaml";
import type {
CliValidationSpec,
EvalCase,
EvalCaseRuntimeSpec,
EvalMode,
EvalValidationSpec,
} from "./types";
const REPO_ROOT = fileURLToPath(new URL("../../", import.meta.url));
const CASES_DIR = path.join(REPO_ROOT, "ai_evals", "cases");
interface RawEvalCase {
id: string;
prompt: string;
initial?: string;
expected?: string;
validate?: EvalValidationSpec;
toolExpect?: EvalCase["toolExpect"];
cliExpect?: CliValidationSpec;
judgeChecklist?: string[];
skipJudge?: boolean;
runtime?: EvalCaseRuntimeSpec;
}
export function getRepoRoot(): string {
return REPO_ROOT;
}
export function getAiEvalsRoot(): string {
return path.join(REPO_ROOT, "ai_evals");
}
export async function loadCases(mode: EvalMode): Promise<EvalCase[]> {
const filePath = path.join(CASES_DIR, `${mode}.yaml`);
const raw = await readFile(filePath, "utf8");
const parsed = parse(raw);
if (!Array.isArray(parsed)) {
throw new Error(`Expected ${filePath} to contain a YAML list of cases`);
}
return (parsed as RawEvalCase[]).map((entry) => ({
id: entry.id,
prompt: entry.prompt,
initialPath: resolveFixturePath(entry.initial),
expectedPath: resolveFixturePath(entry.expected),
validate: entry.validate,
toolExpect: entry.toolExpect,
cliExpect: entry.cliExpect,
judgeChecklist: entry.judgeChecklist,
skipJudge: entry.skipJudge,
runtime: entry.runtime,
}));
}
export async function loadSelectedCases(
mode: EvalMode,
selectedIds: string[]
): Promise<EvalCase[]> {
const allCases = await loadCases(mode);
if (selectedIds.length === 0) {
return allCases;
}
const caseMap = new Map(allCases.map((entry) => [entry.id, entry]));
const missing = selectedIds.filter((id) => !caseMap.has(id));
if (missing.length > 0) {
throw new Error(
`Unknown ${mode} case${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`
);
}
return selectedIds.map((id) => caseMap.get(id)!);
}
function resolveFixturePath(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
return path.isAbsolute(value) ? value : path.join(REPO_ROOT, value);
}
+67
View File
@@ -0,0 +1,67 @@
import { access, copyFile, mkdir, readdir, readFile } from "node:fs/promises";
import path from "node:path";
export async function exists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
export async function readJsonFile<T>(filePath: string): Promise<T> {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as T;
}
export async function readDirectoryFiles(
rootDir: string,
options: {
ignore?: Set<string>;
} = {}
): Promise<Record<string, string>> {
const files: Record<string, string> = {};
await walkDirectory(rootDir, "", files, options.ignore ?? new Set());
return files;
}
export async function copyDirectory(sourceDir: string, targetDir: string): Promise<void> {
const entries = await readdir(sourceDir, { withFileTypes: true });
await mkdir(targetDir, { recursive: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
await copyDirectory(sourcePath, targetPath);
continue;
}
await mkdir(path.dirname(targetPath), { recursive: true });
await copyFile(sourcePath, targetPath);
}
}
async function walkDirectory(
absoluteDir: string,
relativeDir: string,
output: Record<string, string>,
ignore: Set<string>
): Promise<void> {
const entries = await readdir(absoluteDir, { withFileTypes: true });
for (const entry of entries) {
const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
if (ignore.has(relativePath) || ignore.has(entry.name)) {
continue;
}
const absolutePath = path.join(absoluteDir, entry.name);
if (entry.isDirectory()) {
await walkDirectory(absolutePath, relativePath, output, ignore);
continue;
}
output[relativePath] = await readFile(absolutePath, "utf8");
}
}
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it } from "bun:test";
import {
parseFrontendEvalTransport,
resolveFrontendEvalTransportSettings,
} from "./frontendTransport";
const ORIGINAL_ENV = {
WMILL_AI_EVAL_BACKEND_URL: process.env.WMILL_AI_EVAL_BACKEND_URL,
};
afterEach(() => {
if (ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL === undefined) {
delete process.env.WMILL_AI_EVAL_BACKEND_URL;
} else {
process.env.WMILL_AI_EVAL_BACKEND_URL =
ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL;
}
});
describe("parseFrontendEvalTransport", () => {
it("defaults to direct when unset", () => {
expect(parseFrontendEvalTransport(undefined)).toBe("direct");
});
it("accepts proxy explicitly", () => {
expect(parseFrontendEvalTransport("proxy")).toBe("proxy");
});
it("rejects unsupported values", () => {
expect(() => parseFrontendEvalTransport("worker")).toThrow(
"Unsupported frontend eval transport: worker",
);
});
});
describe("resolveFrontendEvalTransportSettings", () => {
it("includes backend settings for proxy transport", () => {
process.env.WMILL_AI_EVAL_BACKEND_URL = "http://127.0.0.1:8000/";
expect(
resolveFrontendEvalTransportSettings({
evalMode: "app",
requestedTransport: "proxy",
}),
).toMatchObject({
transport: "proxy",
backend: {
baseUrl: "http://127.0.0.1:8000",
},
});
});
it("keeps direct transport for cli runs", () => {
expect(
resolveFrontendEvalTransportSettings({
evalMode: "cli",
requestedTransport: "direct",
}),
).toEqual({
transport: "direct",
backend: undefined,
});
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { EvalMode } from "./types";
import type { WindmillBackendSettings } from "./windmillBackendSettings";
import { resolveWindmillBackendSettings } from "./windmillBackendSettings";
export const FRONTEND_EVAL_TRANSPORTS = ["direct", "proxy"] as const;
export type FrontendEvalTransport = (typeof FRONTEND_EVAL_TRANSPORTS)[number];
export interface FrontendEvalTransportSettings {
transport: FrontendEvalTransport;
backend?: WindmillBackendSettings;
}
export function parseFrontendEvalTransport(
value?: string | null,
): FrontendEvalTransport {
const normalized = value?.trim().toLowerCase();
if (!normalized || normalized === "direct") {
return "direct";
}
if (normalized === "proxy") {
return "proxy";
}
throw new Error(
`Unsupported frontend eval transport: ${value}. Use one of: ${FRONTEND_EVAL_TRANSPORTS.join(", ")}`,
);
}
export function resolveFrontendEvalTransportSettings(input: {
evalMode: EvalMode;
requestedTransport?: string | null;
}): FrontendEvalTransportSettings {
const transport = parseFrontendEvalTransport(input.requestedTransport);
if (transport === "proxy" && input.evalMode === "cli") {
throw new Error(
'Frontend eval transport "proxy" is only supported for flow, script, and app evals',
);
}
return {
transport,
backend:
transport === "proxy" ? resolveWindmillBackendSettings() : undefined,
};
}
+158
View File
@@ -0,0 +1,158 @@
import Anthropic from "@anthropic-ai/sdk";
import type { EvalMode, JudgeResult } from "./types";
export const DEFAULT_JUDGE_MODEL = "claude-sonnet-4-6";
const JUDGE_TOOL_NAME = "submit_judgement";
export async function judgeOutput(input: {
mode: EvalMode;
prompt: string;
checklist?: string[];
initial?: unknown;
expected?: unknown;
actual: unknown;
model?: string;
}): Promise<JudgeResult> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return {
success: false,
score: 0,
summary: "Judge unavailable",
error: "ANTHROPIC_API_KEY is not set",
};
}
const client = new Anthropic({ apiKey });
const model = input.model ?? DEFAULT_JUDGE_MODEL;
const system = [
"You evaluate benchmark outputs for Windmill AI generation.",
"Deterministic checks already run separately. Focus on whether the final output satisfies the user request.",
"If expected state is provided, treat it as a valid example and reward semantically equivalent outputs.",
"If a checklist is provided, treat it as the explicit acceptance criteria for this case.",
"Be strict about missing requested functionality.",
"When the prompt wording is ambiguous, prefer the checklist over inferred structural requirements.",
"Do not invent additional Windmill-specific constraints that are not explicit in the prompt, checklist, or expected state.",
"Do not lower the score just because the output uses a different but valid Windmill idiom, naming choice, or equivalent field shape.",
"Do not require exact ids, exact topology, or exact field names unless the prompt, checklist, or expected state clearly requires them.",
...(input.mode === "app"
? [
"For raw app outputs, datatable-backed persistence is a valid Windmill pattern when the app artifact configures datatables.",
"Do not mark `wmill.datatable()` usage as fabricated or invalid by itself.",
"Judge app persistence against the artifact that was actually produced, including any configured datatables.",
]
: []),
`Always respond by calling the ${JUDGE_TOOL_NAME} tool exactly once.`,
].join("\n\n");
const user = [
`Mode: ${input.mode}`,
"",
"User prompt:",
input.prompt,
"",
"Checklist:",
formatChecklist(input.checklist),
"",
"Initial state:",
formatJsonBlock(input.initial),
"",
"Expected state:",
formatJsonBlock(input.expected),
"",
"Actual result:",
formatJsonBlock(input.actual),
].join("\n");
try {
const response = await client.messages.create({
model,
max_tokens: 1024,
temperature: 0,
system,
messages: [{ role: "user", content: user }],
tools: [
{
name: JUDGE_TOOL_NAME,
description: "Submit the benchmark judgement as structured data.",
input_schema: {
type: "object",
properties: {
score: {
type: "integer",
minimum: 0,
maximum: 100,
},
summary: {
type: "string",
},
},
required: ["score", "summary"],
},
},
],
tool_choice: {
type: "tool",
name: JUDGE_TOOL_NAME,
disable_parallel_tool_use: true,
},
});
const toolUseBlock = response.content.find(
(block): block is Anthropic.ToolUseBlock =>
block.type === "tool_use" && block.name === JUDGE_TOOL_NAME
);
if (!toolUseBlock) {
return {
success: false,
score: 0,
summary: "Judge returned no tool output",
error: "Expected structured tool output from judge",
};
}
const parsed = toolUseBlock.input as {
score: number;
summary: string;
};
return {
success: true,
score: normalizeScore(parsed.score),
summary: parsed.summary,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
score: 0,
summary: "Judge failed",
error: message,
};
}
}
function formatJsonBlock(value: unknown): string {
if (value === undefined) {
return "(none)";
}
return JSON.stringify(value, null, 2);
}
function formatChecklist(checklist: string[] | undefined): string {
if (!checklist || checklist.length === 0) {
return "(none)";
}
return checklist.map((item) => `- ${item}`).join("\n");
}
function normalizeScore(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(value)));
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "bun:test";
import { resolveEvalModel } from "./models";
describe("resolveEvalModel", () => {
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({
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",
});
});
it("rejects Gemini aliases for cli evals", () => {
expect(() => resolveEvalModel("cli", "gemini")).toThrow(
"Model gemini-flash is not supported for cli mode"
);
});
});
+185
View File
@@ -0,0 +1,185 @@
import type { EvalMode } from "./types";
export interface FrontendEvalModelConfig {
provider: "anthropic" | "openai" | "googleai";
model: string;
}
export interface CliEvalModelConfig {
provider: "anthropic";
model: string;
}
export interface EvalModelSpec {
id: string;
label: string;
aliases: string[];
frontend?: FrontendEvalModelConfig;
cli?: CliEvalModelConfig;
}
export const EVAL_MODELS: EvalModelSpec[] = [
{
id: "haiku",
label: "Claude Haiku 4.5",
aliases: [
"haiku",
"haiku-4.5",
"claude-haiku",
"claude-haiku-4.5",
"claude-haiku-4-5",
"claude-haiku-4-5-20251001",
],
frontend: {
provider: "anthropic",
model: "claude-haiku-4-5-20251001",
},
cli: {
provider: "anthropic",
model: "haiku",
},
},
{
id: "sonnet",
label: "Claude Sonnet 4.5",
aliases: [
"sonnet",
"sonnet-4.5",
"claude-sonnet",
"claude-sonnet-4.5",
"claude-sonnet-4-5",
"claude-sonnet-4-5-20250929",
],
frontend: {
provider: "anthropic",
model: "claude-sonnet-4-5-20250929",
},
cli: {
provider: "anthropic",
model: "sonnet",
},
},
{
id: "opus",
label: "Claude Opus 4.6",
aliases: [
"opus",
"opus-4.6",
"claude-opus",
"claude-opus-4.6",
"claude-opus-4-6",
],
frontend: {
provider: "anthropic",
model: "claude-opus-4-6",
},
cli: {
provider: "anthropic",
model: "opus",
},
},
{
id: "4o",
label: "GPT-4o",
aliases: ["4o", "gpt-4o"],
frontend: {
provider: "openai",
model: "gpt-4o",
},
},
{
id: "gemini-flash",
label: "Gemini 2.5 Flash",
aliases: ["gemini", "gemini-flash", "gemini-2.5-flash"],
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",
},
},
{
id: "gemini-3-flash-preview",
label: "Gemini 3 Flash Preview",
aliases: ["gemini-3-flash-preview", "gemini-3-flash"],
frontend: {
provider: "googleai",
model: "gemini-3-flash-preview",
},
},
{
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"],
frontend: {
provider: "googleai",
model: "gemini-3.1-pro-preview",
},
},
];
export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec {
const spec = alias ? findEvalModel(alias) : getDefaultEvalModel(mode);
if (!spec) {
throw new Error(`Unknown model: ${alias}`);
}
if (mode === "cli" && !spec.cli) {
throw new Error(`Model ${spec.id} is not supported for cli mode`);
}
if (mode !== "cli" && !spec.frontend) {
throw new Error(`Model ${spec.id} is not supported for ${mode} mode`);
}
return spec;
}
export function getEvalModelHelpText(): string {
return EVAL_MODELS.map((model) => {
const modes = [
...(model.frontend ? ["flow", "script", "app"] : []),
...(model.cli ? ["cli"] : []),
];
return ` ${model.id.padEnd(8)} ${model.label} (${modes.join(", ")})`;
}).join("\n");
}
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 {
if (!model.frontend) {
throw new Error(`Model ${model.id} does not support frontend evals`);
}
return model.frontend;
}
export function getCliEvalModel(model: EvalModelSpec): CliEvalModelConfig {
if (!model.cli) {
throw new Error(`Model ${model.id} does not support cli evals`);
}
return model.cli;
}
function getDefaultEvalModel(mode: EvalMode): EvalModelSpec {
return mode === "cli" ? EVAL_MODELS[0]! : EVAL_MODELS[0]!;
}
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)
);
}
+342
View File
@@ -0,0 +1,342 @@
import { appendFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { getAiEvalsRoot, getRepoRoot } from "./cases";
import type {
BenchmarkArtifactFile,
BenchmarkCaseResult,
BenchmarkRunResult,
BenchmarkTokenUsage,
EvalMode,
} from "./types";
export async function writeRunResult(
result: BenchmarkRunResult,
outputPath?: string,
): Promise<string> {
const targetPath = resolveRunOutputPath(result.mode, outputPath);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(
targetPath,
JSON.stringify(toSerializableRunResult(result), null, 2) + "\n",
"utf8",
);
return targetPath;
}
export async function appendHistoryRecord(
result: BenchmarkRunResult,
historyPath = resolveHistoryPath(result.mode),
): Promise<string> {
await mkdir(path.dirname(historyPath), { recursive: true });
await appendFile(
historyPath,
JSON.stringify(toHistoryRecord(result)) + "\n",
"utf8",
);
return historyPath;
}
export async function writeRunArtifacts(
result: BenchmarkRunResult,
outputPath?: string,
): Promise<string | null> {
const targetPath = resolveRunOutputPath(result.mode, outputPath);
const artifactRoot = defaultArtifactsRoot(targetPath);
await rm(artifactRoot, { recursive: true, force: true });
let wroteArtifacts = false;
for (const caseResult of result.cases) {
for (const attempt of caseResult.attempts) {
const artifactFiles = attempt.artifactFiles ?? [];
if (artifactFiles.length === 0) {
attempt.artifactsPath = null;
continue;
}
const attemptDir = path.join(
artifactRoot,
caseResult.id,
`attempt-${attempt.attempt}`,
);
await writeArtifactFiles(attemptDir, artifactFiles);
attempt.artifactsPath = attemptDir;
wroteArtifacts = true;
}
}
result.artifactsPath = wroteArtifacts ? artifactRoot : null;
return result.artifactsPath ?? null;
}
export function buildRunResult(input: {
mode: EvalMode;
runs: number;
runModel: string | null;
transport?: BenchmarkRunResult["transport"];
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,
);
return {
version: 1,
mode: input.mode,
createdAt: new Date().toISOString(),
gitSha: getGitSha(),
runs: input.runs,
runModel: input.runModel,
transport: input.transport ?? null,
judgeModel: input.judgeModel,
caseCount: input.caseResults.length,
attemptCount,
passedAttempts,
passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount,
averageDurationMs: attemptCount === 0 ? 0 : durationTotal / attemptCount,
totalTokenUsage: tokenUsageTotal,
averageTokenUsagePerAttempt:
attemptCount === 0 || !tokenUsageTotal
? null
: {
prompt: tokenUsageTotal.prompt / attemptCount,
completion: tokenUsageTotal.completion / attemptCount,
total: tokenUsageTotal.total / attemptCount,
},
cases: input.caseResults,
};
}
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`,
];
if (result.transport) {
lines.splice(1, 0, `Transport: ${result.transport}`);
}
const failures = collectFailures(result);
if (failures.length > 0) {
lines.push("Failures:");
for (const entry of failures.slice(0, 10)) {
lines.push(`- ${entry}`);
}
}
return lines.join("\n");
}
function collectFailures(result: BenchmarkRunResult): string[] {
const failures: string[] = [];
for (const caseResult of result.cases) {
for (const attempt of caseResult.attempts) {
if (attempt.passed) {
continue;
}
const failedChecks = attempt.checks
.filter((check) => !check.passed)
.map((check) => check.name);
failures.push(
`${caseResult.id} attempt ${attempt.attempt}: ${failedChecks.join(", ") || attempt.error || "failed"}`,
);
}
}
return failures;
}
function defaultFileName(mode: EvalMode): string {
return `${new Date().toISOString().replaceAll(":", "-")}__${mode}.json`;
}
export function resolveRunOutputPath(
mode: EvalMode,
outputPath?: string,
): string {
return (
outputPath ?? path.join(getAiEvalsRoot(), "results", defaultFileName(mode))
);
}
export function resolveHistoryPath(mode: EvalMode): string {
return path.join(getAiEvalsRoot(), "history", `${mode}.jsonl`);
}
function defaultArtifactsRoot(resultPath: string): string {
return resultPath.endsWith(".json")
? resultPath.slice(0, -".json".length)
: `${resultPath}.artifacts`;
}
async function writeArtifactFiles(
rootDir: string,
files: BenchmarkArtifactFile[],
): Promise<void> {
for (const file of files) {
const relativePath = normalizeArtifactPath(file.path);
const targetPath = path.join(rootDir, relativePath);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, file.content, "utf8");
}
}
function normalizeArtifactPath(filePath: string): string {
const normalized = filePath.replaceAll("\\", "/").replace(/^\/+/, "");
const parts = normalized.split("/").filter(Boolean);
if (
parts.length === 0 ||
parts.some((part) => part === "." || part === "..")
) {
throw new Error(`Invalid artifact path: ${filePath}`);
}
return parts.join("/");
}
function toSerializableRunResult(
result: BenchmarkRunResult,
): BenchmarkRunResult {
return {
...result,
cases: result.cases.map((caseResult) => ({
...caseResult,
attempts: caseResult.attempts.map(
({ artifactFiles, ...attempt }) => attempt,
),
})),
};
}
function toHistoryRecord(result: BenchmarkRunResult) {
const judgeScores = result.cases.flatMap((caseResult) =>
caseResult.attempts.flatMap((attempt) =>
typeof attempt.judgeScore === "number" ? [attempt.judgeScore] : [],
),
);
return {
createdAt: result.createdAt,
gitSha: result.gitSha,
mode: result.mode,
runs: result.runs,
runModel: result.runModel,
transport: result.transport,
judgeModel: result.judgeModel,
caseCount: result.caseCount,
attemptCount: result.attemptCount,
passedAttempts: result.passedAttempts,
passRate: result.passRate,
averageDurationMs: result.averageDurationMs,
averageJudgeScore:
judgeScores.length === 0
? null
: judgeScores.reduce((sum, score) => sum + score, 0) /
judgeScores.length,
averageTokenUsagePerAttempt: result.averageTokenUsagePerAttempt ?? null,
failedCaseIds: Array.from(
new Set(
result.cases
.filter((caseResult) =>
caseResult.attempts.some((attempt) => !attempt.passed),
)
.map((caseResult) => caseResult.id),
),
),
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 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,
attemptCount,
passedAttempts,
passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount,
averageDurationMs:
attemptCount === 0 ? 0 : totalDurationMs / attemptCount,
averageJudgeScore:
judgeScores.length === 0
? null
: judgeScores.reduce((sum, score) => sum + score, 0) /
judgeScores.length,
averageTokenUsagePerAttempt:
attemptCount === 0 || !totalTokenUsage
? null
: {
prompt: totalTokenUsage.prompt / attemptCount,
completion: totalTokenUsage.completion / attemptCount,
total: totalTokenUsage.total / attemptCount,
},
};
}),
};
}
function getGitSha(): string | null {
try {
return execFileSync("git", ["rev-parse", "HEAD"], {
cwd: getRepoRoot(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return null;
}
}
function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
+323
View File
@@ -0,0 +1,323 @@
import { judgeOutput, DEFAULT_JUDGE_MODEL } from "./judge";
import type {
BenchmarkAttemptResult,
BenchmarkCaseResult,
BenchmarkCheck,
EvalCase,
FrontendBenchmarkProgressEvent,
ModeRunner,
} from "./types";
import { validateToolExpectations } from "./validators";
export async function runSuite<TInitial, TExpected, TActual>(input: {
modeRunner: ModeRunner<TInitial, TExpected, TActual>;
cases: EvalCase[];
runs: number;
runModel: string | null;
judgeModel?: string | null;
concurrency?: number;
verbose?: boolean;
onProgress?: (event: FrontendBenchmarkProgressEvent) => void;
}): Promise<BenchmarkCaseResult[]> {
const judgeModel = input.judgeModel ?? DEFAULT_JUDGE_MODEL;
const concurrency = Math.max(1, input.concurrency ?? input.modeRunner.concurrency);
const results = new Array<BenchmarkCaseResult>(input.cases.length);
let cursor = 0;
if (input.modeRunner.mode !== "cli") {
input.onProgress?.({
type: "run-start",
surface: input.modeRunner.mode,
totalCases: input.cases.length,
runs: input.runs,
concurrency,
});
}
async function worker(): Promise<void> {
while (true) {
const caseIndex = cursor++;
if (caseIndex >= input.cases.length) {
return;
}
const evalCase = input.cases[caseIndex];
results[caseIndex] = {
id: evalCase.id,
prompt: evalCase.prompt,
initialPath: evalCase.initialPath,
expectedPath: evalCase.expectedPath,
attempts: await runCaseAttempts({
caseIndex,
evalCase,
runs: input.runs,
judgeModel,
judgeThreshold: input.modeRunner.judgeThreshold ?? 80,
modeRunner: input.modeRunner,
totalCases: input.cases.length,
verbose: input.verbose ?? false,
onProgress: input.onProgress,
}),
};
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, input.cases.length) }, () => worker())
);
return results;
}
async function runCaseAttempts<TInitial, TExpected, TActual>(input: {
caseIndex: number;
evalCase: EvalCase;
runs: number;
judgeModel: string;
judgeThreshold: number;
modeRunner: ModeRunner<TInitial, TExpected, TActual>;
totalCases: number;
verbose: boolean;
onProgress?: (event: FrontendBenchmarkProgressEvent) => void;
}): Promise<BenchmarkAttemptResult[]> {
const attempts: BenchmarkAttemptResult[] = [];
const surface = input.modeRunner.mode === "cli" ? null : input.modeRunner.mode;
for (let attempt = 1; attempt <= input.runs; attempt += 1) {
if (surface) {
input.onProgress?.({
type: "attempt-start",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
});
}
const startedAt = Date.now();
try {
const initial = await input.modeRunner.loadInitial(input.evalCase.initialPath);
const expected = await input.modeRunner.loadExpected(input.evalCase.expectedPath);
const run = await input.modeRunner.run(input.evalCase.prompt, initial, {
evalCase: input.evalCase,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
verbose: input.verbose,
onAssistantMessageStart: input.verbose && surface
? () =>
input.onProgress?.({
type: "assistant-message-start",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
})
: undefined,
onAssistantChunk: input.verbose && surface
? (chunk: string) =>
input.onProgress?.({
type: "assistant-chunk",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
chunk,
})
: undefined,
onAssistantMessageEnd: input.verbose && surface
? () =>
input.onProgress?.({
type: "assistant-message-end",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
})
: undefined,
onToolCall: input.verbose && surface
? ({ toolName, argumentsText }) =>
input.onProgress?.({
type: "tool-call",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
toolName,
argumentsText,
})
: undefined,
});
const checks: BenchmarkCheck[] = [
buildCheck("run succeeded", run.success, run.error),
...input.modeRunner.validate({
evalCase: input.evalCase,
prompt: input.evalCase.prompt,
initial,
expected,
actual: run.actual,
run,
}),
...validateToolExpectations({
run,
toolExpect: input.evalCase.toolExpect,
}),
];
const artifactFiles = input.modeRunner.buildArtifacts?.(run.actual) ?? [];
if (run.success && input.modeRunner.backendValidate) {
try {
const backendValidation = await input.modeRunner.backendValidate({
evalCase: input.evalCase,
prompt: input.evalCase.prompt,
initial,
expected,
actual: run.actual,
run,
context: {
evalCase: input.evalCase,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
verbose: input.verbose,
onAssistantMessageStart: undefined,
onAssistantChunk: undefined,
onAssistantMessageEnd: undefined,
},
});
if (backendValidation) {
checks.push(...backendValidation.checks);
artifactFiles.push(...(backendValidation.artifactFiles ?? []));
}
} catch (error) {
checks.push(
buildCheck(
"backend validation succeeded",
false,
error instanceof Error ? error.message : String(error)
)
);
}
}
let judgeScore: number | null = null;
let judgeSummary: string | null = null;
if (run.success && !input.evalCase.skipJudge) {
const judge = await judgeOutput({
mode: input.modeRunner.mode,
prompt: input.evalCase.prompt,
checklist: input.evalCase.judgeChecklist,
initial,
expected: input.modeRunner.mode === "cli" ? undefined : expected,
actual: run.actual,
model: input.judgeModel,
});
judgeScore = judge.success ? judge.score : null;
judgeSummary = judge.summary;
checks.push(buildCheck("judge succeeded", judge.success, judge.error));
checks.push(
buildCheck(
`judge score >= ${input.judgeThreshold}`,
(judgeScore ?? 0) >= input.judgeThreshold,
judge.success ? `score=${judgeScore}` : judge.error
)
);
}
const attemptResult: BenchmarkAttemptResult = {
attempt,
passed: checks.every((check) => check.passed),
durationMs: Date.now() - startedAt,
assistantMessageCount: run.assistantMessageCount,
toolCallCount: run.toolCallCount,
toolsUsed: uniqueStrings(run.toolsUsed),
toolCallDetails: run.toolCallDetails,
skillsInvoked: uniqueStrings(run.skillsInvoked),
checks,
judgeScore,
judgeSummary,
error: run.error ?? null,
tokenUsage: run.tokenUsage ?? null,
artifactsPath: null,
artifactFiles,
};
if (surface) {
input.onProgress?.({
type: "attempt-finish",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
passed: attemptResult.passed,
durationMs: attemptResult.durationMs,
judgeScore: attemptResult.judgeScore,
error: attemptResult.error,
});
}
attempts.push(attemptResult);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const failedAttempt: BenchmarkAttemptResult = {
attempt,
passed: false,
durationMs: Date.now() - startedAt,
assistantMessageCount: 0,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
checks: [buildCheck("run crashed", false, message)],
judgeScore: null,
judgeSummary: null,
error: message,
tokenUsage: null,
};
if (surface) {
input.onProgress?.({
type: "attempt-finish",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
passed: false,
durationMs: failedAttempt.durationMs,
judgeScore: null,
error: message,
});
}
attempts.push(failedAttempt);
}
}
return attempts;
}
function buildCheck(name: string, passed: boolean, details?: string): BenchmarkCheck {
return details ? { name, passed, details } : { name, passed };
}
function uniqueStrings(values: string[]): string[] {
return [...new Set(values)];
}
+381
View File
@@ -0,0 +1,381 @@
export const EVAL_MODES = ["cli", "flow", "script", "app"] as const;
export type EvalMode = (typeof EVAL_MODES)[number];
export type FrontendEvalTransport = "direct" | "proxy";
export interface EvalCaseRuntimeBackendPreview {
args?: Record<string, unknown>;
timeoutSeconds?: number;
}
export type EvalCaseRuntimeAppAdditionalContext =
| {
type: "frontend";
path: string;
}
| {
type: "backend";
key: string;
}
| {
type: "datatable";
datatableName: string;
schema: string;
table: string;
};
export interface EvalCaseRuntimeAppContextSpec {
additional?: EvalCaseRuntimeAppAdditionalContext[];
}
export interface EvalCaseRuntimeSpec {
maxTurns?: number;
backendPreview?: EvalCaseRuntimeBackendPreview;
appContext?: EvalCaseRuntimeAppContextSpec;
}
export interface FlowValidationSpec {
schemaRequiredPaths?: string[];
schemaAnyOf?: Array<{
requiredPaths: string[];
}>;
exactTopLevelStepIds?: string[];
topLevelStepIds?: string[];
topLevelStepOrder?: string[];
topLevelStepTypeCountsAtLeast?: Array<{
type: string;
count: number;
}>;
topLevelStepTypes?: Array<{
id: string;
type: string;
}>;
moduleRules?: Array<{
id: string;
hasStopAfterIf?: boolean;
hasStopAfterAllItersIf?: boolean;
immediateChildStepIds?: string[];
exactImmediateChildStepIds?: string[];
immediateChildStepTypes?: Array<{
id: string;
type: string;
}>;
requiredInputTransforms?: Array<{
type?: string;
expr?: string;
exprAnyOf?: string[];
value?: string | number | boolean | null;
}>;
}>;
moduleFieldRules?: Array<{
id: string;
path: string;
equals: string | number | boolean | null;
}>;
resolveResultsRefs?: boolean;
requireSpecialModules?: Array<"preprocessor_module" | "failure_module">;
requireSuspendSteps?: Array<{
id: string;
requiredEvents?: number;
resumeRequiredStringFieldAnyOf?: string[];
}>;
}
export interface AppValidationSpec {
requiredFrontendPaths?: string[];
requiredFrontendFileContent?: Array<{
path: string;
includes: string[];
}>;
requiredBackendRunnableKeys?: string[];
requiredBackendRunnableTypes?: Array<{
key: string;
type: string;
}>;
requiredBackendRunnableContent?: Array<{
key: string;
includes: string[];
}>;
backendRunnableCountAtLeast?: number;
datatableCountAtLeast?: number;
datatableTableCountAtLeast?: number;
datatableTableCountExactly?: number;
requiredDatatables?: Array<{
schema: string;
table: string;
datatableName?: string;
}>;
requiredToolsUsed?: string[];
forbiddenAppContent?: string[];
}
export interface CliValidationSpec {
requiredSkills?: string[];
forbiddenSkills?: string[];
requiredSkillsBeforeFirstMutation?: string[];
requiredAssistantMentions?: string[];
forbiddenAssistantMentions?: string[];
orderedAssistantMentions?: string[];
requiredProposedCommands?: string[];
forbiddenProposedCommands?: string[];
orderedProposedCommands?: string[];
forbiddenExecutedCommands?: string[];
workspaceUnchanged?: boolean;
}
export interface ToolCallDetail {
name: string;
arguments: unknown;
}
export interface ToolCallArgumentRule {
tool: string;
field: string;
stringStartsWithAnyOf?: string[];
stringMustNotStartWithAnyOf?: string[];
}
export interface ToolValidationSpec {
requiredToolsUsed?: string[];
toolCallArgs?: ToolCallArgumentRule[];
}
export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec;
export interface EvalCase {
id: string;
prompt: string;
initialPath?: string;
expectedPath?: string;
validate?: EvalValidationSpec;
toolExpect?: ToolValidationSpec;
cliExpect?: CliValidationSpec;
judgeChecklist?: string[];
skipJudge?: boolean;
runtime?: EvalCaseRuntimeSpec;
}
export interface BenchmarkCheck {
name: string;
passed: boolean;
details?: string;
}
export interface JudgeResult {
success: boolean;
score: number;
summary: string;
error?: string;
}
export interface BenchmarkArtifactFile {
path: string;
content: string;
}
export interface BackendValidationResult {
checks: BenchmarkCheck[];
artifactFiles?: BenchmarkArtifactFile[];
}
export interface BenchmarkTokenUsage {
prompt: number;
completion: number;
total: number;
}
export interface CliToolInvocation {
tool: string;
input: Record<string, unknown>;
timestamp: number;
}
export interface CliWmillInvocation {
argv: string[];
cwd: string;
timestamp: string;
}
export interface CliTrace {
toolsUsed: CliToolInvocation[];
skillsInvoked: string[];
assistantMessageCount: number;
bashCommands: string[];
proposedCommands: string[];
executedWmillCommands: string[];
wmillInvocations: CliWmillInvocation[];
firstMutationToolIndex: number | null;
}
export interface ModeRunOutput<TActual> {
success: boolean;
actual: TActual;
error?: string;
assistantMessageCount: number;
toolCallCount: number;
toolsUsed: string[];
toolCallDetails?: ToolCallDetail[];
skillsInvoked: string[];
tokenUsage?: BenchmarkTokenUsage | null;
}
export interface ModeRunContext {
evalCase?: EvalCase;
caseId: string;
caseNumber: number;
totalCases: number;
attempt: number;
runs: number;
verbose: boolean;
onAssistantMessageStart?: () => void;
onAssistantChunk?: (chunk: string) => void;
onAssistantMessageEnd?: () => void;
onToolCall?: (input: { toolName: string; argumentsText: string }) => void;
}
export interface ModeRunner<TInitial, TExpected, TActual> {
mode: EvalMode;
concurrency: number;
judgeThreshold?: number;
loadInitial(path?: string): Promise<TInitial | undefined>;
loadExpected(path?: string): Promise<TExpected | undefined>;
run(
prompt: string,
initial: TInitial | undefined,
context: ModeRunContext,
): Promise<ModeRunOutput<TActual>>;
validate(input: {
evalCase: EvalCase;
prompt: string;
initial: TInitial | undefined;
expected: TExpected | undefined;
actual: TActual;
run: ModeRunOutput<TActual>;
}): BenchmarkCheck[];
backendValidate?(input: {
evalCase: EvalCase;
prompt: string;
initial: TInitial | undefined;
expected: TExpected | undefined;
actual: TActual;
run: ModeRunOutput<TActual>;
context: ModeRunContext;
}): Promise<BackendValidationResult | null>;
buildArtifacts?(actual: TActual): BenchmarkArtifactFile[];
}
export interface BenchmarkAttemptResult {
attempt: number;
passed: boolean;
durationMs: number;
assistantMessageCount: number;
toolCallCount: number;
toolsUsed: string[];
toolCallDetails?: ToolCallDetail[];
skillsInvoked: string[];
checks: BenchmarkCheck[];
judgeScore: number | null;
judgeSummary: string | null;
error: string | null;
tokenUsage?: BenchmarkTokenUsage | null;
artifactsPath?: string | null;
artifactFiles?: BenchmarkArtifactFile[];
}
export interface BenchmarkCaseResult {
id: string;
prompt: string;
initialPath?: string;
expectedPath?: string;
attempts: BenchmarkAttemptResult[];
}
export interface BenchmarkRunResult {
version: 1;
mode: EvalMode;
createdAt: string;
gitSha: string | null;
runs: number;
runModel: string | null;
transport: FrontendEvalTransport | null;
judgeModel: string | null;
caseCount: number;
attemptCount: number;
passedAttempts: number;
passRate: number;
averageDurationMs: number;
totalTokenUsage?: BenchmarkTokenUsage | null;
averageTokenUsagePerAttempt?: BenchmarkTokenUsage | null;
artifactsPath?: string | null;
cases: BenchmarkCaseResult[];
}
export type FrontendBenchmarkProgressEvent =
| {
type: "run-start";
surface: Exclude<EvalMode, "cli">;
totalCases: number;
runs: number;
concurrency: number;
}
| {
type: "attempt-start";
surface: Exclude<EvalMode, "cli">;
caseId: string;
caseNumber: number;
totalCases: number;
attempt: number;
runs: number;
}
| {
type: "attempt-finish";
surface: Exclude<EvalMode, "cli">;
caseId: string;
caseNumber: number;
totalCases: number;
attempt: number;
runs: number;
passed: boolean;
durationMs: number;
judgeScore: number | null;
error: string | null;
}
| {
type: "assistant-message-start";
surface: Exclude<EvalMode, "cli">;
caseId: string;
caseNumber: number;
totalCases: number;
attempt: number;
runs: number;
}
| {
type: "assistant-chunk";
surface: Exclude<EvalMode, "cli">;
caseId: string;
caseNumber: number;
totalCases: number;
attempt: number;
runs: number;
chunk: string;
}
| {
type: "assistant-message-end";
surface: Exclude<EvalMode, "cli">;
caseId: string;
caseNumber: number;
totalCases: number;
attempt: number;
runs: number;
}
| {
type: "tool-call";
surface: Exclude<EvalMode, "cli">;
caseId: string;
caseNumber: number;
totalCases: number;
attempt: number;
runs: number;
toolName: string;
argumentsText: string;
};
+550
View File
@@ -0,0 +1,550 @@
import { describe, expect, it } from "bun:test";
import {
validateAppState,
validateCliWorkspace,
validateScriptState,
validateToolExpectations,
} from "./validators";
describe("validateScriptState", () => {
it("accepts semantically equivalent script implementations", () => {
const checks = validateScriptState({
actual: {
path: "f/evals/greet_user.ts",
lang: "bun",
code: "export async function main(name: string): Promise<string> {\n return `Hello, ${name}!`;\n}\n",
},
expected: {
path: "f/evals/greet_user.ts",
lang: "bun",
code: "export async function main(name: string) {\n\treturn `Hello, ${name}!`\n}\n",
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("still requires an exported main entrypoint", () => {
const checks = validateScriptState({
actual: {
path: "f/evals/greet_user.ts",
lang: "bun",
code: "async function main(name: string) {\n return `Hello, ${name}!`;\n}\n",
},
});
expect(checks).toContainEqual({
name: "script exports entrypoint",
passed: false,
});
});
});
describe("validateToolExpectations", () => {
it("accepts Windmill-prefixed schedule paths", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["create_schedule"],
toolCallDetails: [
{
name: "create_schedule",
arguments: {
path: "f/evals/greet_user_daily",
},
},
],
skillsInvoked: [],
},
toolExpect: {
requiredToolsUsed: ["create_schedule"],
toolCallArgs: [
{
tool: "create_schedule",
field: "path",
stringStartsWithAnyOf: ["f/", "u/"],
stringMustNotStartWithAnyOf: ["schedules/"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("rejects schedule-prefixed tool paths", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["create_schedule"],
toolCallDetails: [
{
name: "create_schedule",
arguments: {
path: "schedules/greet_user_daily",
},
},
],
skillsInvoked: [],
},
toolExpect: {
requiredToolsUsed: ["create_schedule"],
toolCallArgs: [
{
tool: "create_schedule",
field: "path",
stringStartsWithAnyOf: ["f/", "u/"],
stringMustNotStartWithAnyOf: ["schedules/"],
},
],
},
});
expect(checks).toContainEqual({
name: "create_schedule.path uses an accepted prefix",
passed: false,
details: 'accepted prefixes: f/, u/; values: "schedules/greet_user_daily"',
});
expect(checks).toContainEqual({
name: "create_schedule.path avoids rejected prefixes",
passed: false,
details: 'rejected prefixes: schedules/; values: "schedules/greet_user_daily"',
});
});
});
describe("validateAppState", () => {
it("accepts app persistence requirements when a datatable table is registered", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx": "import { backend } from 'wmill'\nexport default function App() { return <div /> }\n",
},
backend: {
listRecipes: {
name: "List recipes",
type: "inline",
inlineScript: {
language: "bun",
content:
"import * as wmill from 'windmill-client'\nexport async function main() { const sql = wmill.datatable(); return await sql`select * from recipes`.fetch() }\n",
},
},
},
datatables: [
{
datatable_name: "main",
schemas: {
public: {
recipes: {},
},
},
},
],
},
validate: {
datatableTableCountAtLeast: 1,
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails app persistence requirements when no datatable table exists", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx": "export default function App() { return <div /> }\n",
},
backend: {},
datatables: [],
},
validate: {
datatableTableCountAtLeast: 1,
},
});
expect(checks).toContainEqual({
name: "app includes at least 1 datatable table",
passed: false,
details: "expected at least 1, got 0",
});
});
it("requires a specific datatable table when requested", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx": "export default function App() { return <div /> }\n",
},
backend: {},
datatables: [
{
datatable_name: "main",
schemas: {
public: {
recipes: {},
},
},
},
],
},
validate: {
requiredDatatables: [
{
datatableName: "main",
schema: "public",
table: "recipes",
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("can require an exact datatable table count", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx": "export default function App() { return <div /> }\n",
},
backend: {},
datatables: [
{
datatable_name: "main",
schemas: {
public: {
notes: {},
extra_notes: {},
},
},
},
],
},
validate: {
datatableTableCountExactly: 1,
},
});
expect(checks).toContainEqual({
name: "app includes exactly 1 datatable table",
passed: false,
details: "expected exactly 1, got 2",
});
});
it("validates app datatable code, tool usage, and forbidden storage", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx":
"import { backend } from './wmill'\nexport default function App() { void backend.listNotes(); return <div /> }\n",
},
backend: {
listNotes: {
name: "List notes",
type: "inline",
inlineScript: {
language: "bun",
content:
"import * as wmill from 'windmill-client'\nexport async function main() { const sql = wmill.datatable(); return await sql`SELECT * FROM notes`.fetch() }\n",
},
},
},
datatables: [
{
datatable_name: "main",
schemas: {
public: {
notes: {},
},
},
},
],
},
toolsUsed: ["list_datatables", "get_datatable_table_schema"],
validate: {
requiredFrontendFileContent: [
{
path: "/index.tsx",
includes: ["backend.listNotes"],
},
],
requiredBackendRunnableContent: [
{
key: "listNotes",
includes: ["wmill.datatable", "select", "notes"],
},
],
requiredToolsUsed: ["list_datatables", "get_datatable_table_schema"],
forbiddenAppContent: ["localStorage", "sessionStorage"],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails app datatable code validation when required code or tools are missing", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx": "export default function App() { localStorage.setItem('x', 'y'); return <div /> }\n",
},
backend: {
listNotes: {
name: "List notes",
type: "inline",
inlineScript: {
language: "bun",
content: "export async function main() { return [] }\n",
},
},
},
datatables: [],
},
toolsUsed: ["list_files"],
validate: {
requiredBackendRunnableContent: [
{
key: "listNotes",
includes: ["wmill.datatable", "notes"],
},
],
requiredToolsUsed: ["list_datatables"],
forbiddenAppContent: ["localStorage"],
},
});
expect(checks).toContainEqual({
name: "listNotes backend runnable includes required content",
passed: false,
details: "missing snippets: wmill.datatable, notes",
});
expect(checks).toContainEqual({
name: "tool list_datatables was used",
passed: false,
details: "tools used: list_files",
});
expect(checks).toContainEqual({
name: "app does not include forbidden content 'localStorage'",
passed: false,
details: "forbidden snippet: localStorage",
});
});
it("fails validation when frontend references a missing backend runnable", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx":
"import { backend } from 'wmill'\nexport default function App() { void backend.deleteRecipe({ id: 1 }); return <div /> }\n",
},
backend: {
listRecipes: {
name: "List recipes",
type: "inline",
inlineScript: {
language: "bun",
content: "export async function main() { return [] }\n",
},
},
},
datatables: [],
},
});
expect(checks).toContainEqual({
name: "frontend backend references resolve",
passed: false,
details: expect.stringContaining("deleteRecipe"),
});
});
});
describe("validateCliWorkspace", () => {
it("accepts required CLI skills and proposed commands without execution", () => {
const checks = validateCliWorkspace({
actualFiles: {
"f/evals/hello.ts": "export async function main(name: string) { return { greeting: `Hello, ${name}!` } }\n",
},
expectedFiles: {
"f/evals/hello.ts": "export async function main(name: string)\nreturn { greeting: `Hello, ${name}!` }",
},
assistantOutput:
"Created the script. Next run `wmill generate-metadata --yes` and then `wmill sync push`.",
trace: {
toolsUsed: [
{ tool: "Skill", input: { skill: "write-script-bun" }, timestamp: 1 },
{ tool: "Write", input: { file_path: "f/evals/hello.ts" }, timestamp: 2 },
],
skillsInvoked: ["write-script-bun"],
assistantMessageCount: 1,
bashCommands: [],
proposedCommands: ["wmill generate-metadata --yes", "wmill sync push"],
executedWmillCommands: [],
wmillInvocations: [],
firstMutationToolIndex: 1,
},
cliExpect: {
requiredSkills: ["write-script-bun"],
requiredSkillsBeforeFirstMutation: ["write-script-bun"],
orderedAssistantMentions: ["wmill generate-metadata", "wmill sync push"],
orderedProposedCommands: ["wmill generate-metadata", "wmill sync push"],
forbiddenExecutedCommands: ["^wmill generate-metadata", "^wmill sync push"],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails when a forbidden wmill command is executed", () => {
const checks = validateCliWorkspace({
actualFiles: {},
assistantOutput: "Run `wmill sync push` when ready.",
trace: {
toolsUsed: [{ tool: "Bash", input: { command: "wmill sync push" }, timestamp: 1 }],
skillsInvoked: [],
assistantMessageCount: 1,
bashCommands: ["wmill sync push"],
proposedCommands: ["wmill sync push"],
executedWmillCommands: ["wmill sync push"],
wmillInvocations: [
{
argv: ["sync", "push"],
cwd: "/tmp/workspace",
timestamp: "2026-04-21T12:00:00+00:00",
},
],
firstMutationToolIndex: 0,
},
cliExpect: {
forbiddenExecutedCommands: ["^wmill sync push"],
},
});
expect(checks).toContainEqual({
name: "does not execute ^wmill sync push",
passed: false,
details: "executed=wmill sync push",
});
});
it("supports read-only guidance cases that must keep the workspace unchanged", () => {
const checks = validateCliWorkspace({
actualFiles: {},
assistantOutput:
"Use `wmill job get 123`, then `wmill job logs 123`, then `wmill job result 123`.",
trace: {
toolsUsed: [{ tool: "Skill", input: { skill: "cli-commands" }, timestamp: 1 }],
skillsInvoked: ["cli-commands"],
assistantMessageCount: 1,
bashCommands: [],
proposedCommands: ["wmill job get 123", "wmill job logs 123", "wmill job result 123"],
executedWmillCommands: [],
wmillInvocations: [],
firstMutationToolIndex: null,
},
cliExpect: {
requiredSkills: ["cli-commands"],
workspaceUnchanged: true,
orderedProposedCommands: [
"wmill job get 123",
"wmill job logs 123",
"wmill job result 123",
],
forbiddenProposedCommands: ["wmill sync push"],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("matches skills by exact name instead of substring", () => {
const checks = validateCliWorkspace({
actualFiles: {},
assistantOutput: "No workspace changes needed.",
trace: {
toolsUsed: [{ tool: "Skill", input: { skill: "write-flow-helper" }, timestamp: 1 }],
skillsInvoked: ["write-flow-helper"],
assistantMessageCount: 1,
bashCommands: [],
proposedCommands: [],
executedWmillCommands: [],
wmillInvocations: [],
firstMutationToolIndex: null,
},
cliExpect: {
requiredSkills: ["write-flow"],
forbiddenSkills: ["write-flow"],
},
});
expect(checks).toContainEqual({
name: "invokes skill write-flow",
passed: false,
details: "skills=write-flow-helper",
});
expect(checks).toContainEqual({
name: "does not invoke skill write-flow",
passed: true,
});
});
it("accepts ordered proposed commands when they appear in one concatenated entry", () => {
const checks = validateCliWorkspace({
actualFiles: {},
assistantOutput: "Run wmill generate-metadata and then wmill sync push.",
trace: {
toolsUsed: [{ tool: "Skill", input: { skill: "cli-commands" }, timestamp: 1 }],
skillsInvoked: ["cli-commands"],
assistantMessageCount: 1,
bashCommands: [],
proposedCommands: ["wmill generate-metadata and then wmill sync push"],
executedWmillCommands: [],
wmillInvocations: [],
firstMutationToolIndex: null,
},
cliExpect: {
orderedProposedCommands: ["wmill generate-metadata", "wmill sync push"],
},
});
expect(checks).toContainEqual({
name: "assistant proposes expected commands in order",
passed: true,
});
});
it("fails skill-before-mutation checks cleanly when no mutation happened", () => {
const checks = validateCliWorkspace({
actualFiles: {},
assistantOutput: "Run `wmill sync pull` first.",
trace: {
toolsUsed: [{ tool: "Skill", input: { skill: "cli-commands" }, timestamp: 1 }],
skillsInvoked: ["cli-commands"],
assistantMessageCount: 1,
bashCommands: [],
proposedCommands: ["wmill sync pull"],
executedWmillCommands: [],
wmillInvocations: [],
firstMutationToolIndex: null,
},
cliExpect: {
requiredSkillsBeforeFirstMutation: ["cli-commands"],
},
});
expect(checks).toContainEqual({
name: "invokes skill cli-commands before first mutation",
passed: false,
details: "firstSkillIndex=0; firstMutationIndex=none",
});
});
});
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
export interface WindmillBackendSettings {
baseUrl: string;
email: string;
password: string;
keepWorkspaces: boolean;
workspaceOverride?: string;
workspacePrefix: string;
}
export function resolveWindmillBackendSettings(): WindmillBackendSettings {
return {
baseUrl: normalizeBaseUrl(
process.env.WMILL_AI_EVAL_BACKEND_URL ??
process.env.WINDMILL_URL ??
process.env.WINDMILL_BASE_URL ??
process.env.REMOTE ??
"http://127.0.0.1:8000",
),
email: process.env.WMILL_AI_EVAL_BACKEND_EMAIL ?? "admin@windmill.dev",
password: process.env.WMILL_AI_EVAL_BACKEND_PASSWORD ?? "changeme",
keepWorkspaces: isTruthy(process.env.WMILL_AI_EVAL_KEEP_WORKSPACES),
workspaceOverride: sanitizeOptionalWorkspaceId(
process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE,
),
workspacePrefix: sanitizeWorkspacePrefix(
process.env.WMILL_AI_EVAL_WORKSPACE_PREFIX ?? "ai-evals",
),
};
}
export function parsePositiveInteger(
value: string | undefined,
fallback: number,
): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, "");
}
function sanitizeWorkspacePrefix(value: string): string {
const sanitized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
return sanitized.length > 0 ? sanitized : "ai-evals";
}
function sanitizeOptionalWorkspaceId(
value: string | undefined,
): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function isTruthy(value: string | undefined): boolean {
if (!value) {
return false;
}
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
@@ -0,0 +1,2 @@
main(name: string)
greeting: `Hello, ${name}!`
@@ -0,0 +1,3 @@
export async function main(name: string) {
return { greeting: `Hello, ${name}!` };
}
@@ -0,0 +1,2 @@
type: script
path: f/lib/format_greeting
@@ -0,0 +1,3 @@
export async function main(name: string) {
return { greeting: `Hello, ${name}!` };
}
@@ -0,0 +1,2 @@
def main(
return {"total": a + b}
@@ -0,0 +1,20 @@
summary: Simple greeting flow
schema:
type: object
properties:
name:
type: string
description: Name to greet
required:
- name
value:
modules:
- id: hello_step
value:
type: rawscript
language: bun
content: !inline hello.ts
input_transforms:
name:
type: javascript
expr: flow_input.name
@@ -0,0 +1,3 @@
export async function main(name: string) {
return { greeting: `Hello, ${name}!` };
}
@@ -0,0 +1,3 @@
export async function main(name: string) {
return { greeting: `Hello, ${name}!` };
}
@@ -0,0 +1,3 @@
export async function main(name: string) {
return { greeting: `Hello, ${name}!` };
}

Some files were not shown because too many files have changed in this diff Show More