Compare commits

...
Author SHA1 Message Date
Guilhem LemouelandClaude Opus 4.8 e6ace03c17 feat(sessions): prototype session-mode layout wrapper (design exploration)
Do not merge — design exploration of an optional full-page 'session mode' layout for AI sessions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:18:54 +02:00
GuilhemandClaude Opus 4.8 2e020b2ccc feat(ai-chat): context usage gauge + unified model settings menu (#9763)
* feat(ai-chat): show context usage as a gauge with hover tooltip

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

* feat(ai-chat): consolidate model, thinking & params into one dropdown

Merge the model picker, reasoning-effort selector and prompt settings
into a single dropdown with a model list, a thinking-effort slider and a
hover-revealed Parameters submenu. The trigger shows the model and effort.

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

* feat(ai-chat): polish model/thinking dropdown interactions

Register the model rows and thinking slider as melt menu items (roving
highlight + arrow-key navigation), keep the menu open on selection via a
new DropdownV2 closeOnItemClick prop, use melt's createSubmenu for the
Parameters flyout so it flips on screen edges, and use the brand accent
for the context-usage gauge and slider.

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

* fix(ai-chat): stop popover drift and keep Thinking section when unsupported

Freeze the trigger width while the dropdown is open so the bottom-end
popover doesn't shift as the effort label resizes (released on close, so
no reserved padding). When a model has no reasoning support, show the
Thinking section disabled with a message instead of removing it.

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

* fix(ai-chat): restore reasoning slider drag inside the menu

The slider lives in a melt menu item, whose roving focus blurs the
focused element on pointermove and aborted the native thumb drag. Stop
the slider's pointer events from bubbling to the item so melt leaves it
alone; focus-based highlighting still works.

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

* feat(ai-chat): move Parameters to the top of the model settings menu

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

* feat(ai-chat): hide the @ context picker in global mode

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

* fix(ai-chat): only mark context gauge as a meter when the window is known

A meter is a 0–100% reading; with an unknown context window there is no max
to measure against, so role/aria-value* are dropped (previously valuenow fell
back to the raw token count against an implicit valuemax of 100).

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

* docs(frontend): note closeOnItemClick is read at mount-time

Addresses a non-blocking review note on DropdownV2.

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

* docs(ai-chat): fix showContextPicker comment to match GLOBAL removal

Addresses Pi review P2: GLOBAL no longer offers the @ context picker.

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

* docs(ai-chat): clarify showContextPicker hides only the manual @ button

In GLOBAL, @-context is still invoked inline by typing @ in the input; only
the redundant picker button is hidden.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:07:20 +00:00
Ruben FiszelandClaude Opus 4.8 4dbf873723 fix(frontend): stop flow step id generation from being poisoned by non-canonical keys (#9766)
* fix(frontend): stop flow step id generation from being poisoned by non-canonical keys

nextId computed the next step id from the max of charsToNumber over every
module id and flowState key. Only canonical auto-ids (a, b, ... aa, ab) have a
meaningful charsToNumber value, but flowState also holds copy ids ("z2"),
subflow result keys ("subflow:..."), reserved keys ("failure"/"preprocessor")
and user-renamed ids. The old `length >= 4` guard filtered long junk but let
short junk through, so e.g. duplicating step "z" (key "z2", charsToNumber 629)
made the next new step jump to "xg" and escalate from there.

nextId now only counts a key if it round-trips through numberToChars and is not
reserved, and the broken length cap is removed so large flows still get correct
ids.

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

* fix(frontend): keep length cap in nextId to avoid regressing long renames

Address CI review: removing the length cap made all-lowercase renamed step
ids (e.g. "process", which round-trips through numberToChars) feed into the
max and poison id generation again — a regression versus the prior behavior,
since step ids can be renamed to ^[a-zA-Z][a-zA-Z0-9_]*$.

Restore the length>=4 skip and pair it with the round-trip canonical check,
so short non-canonical keys (copy ids "z2"/"c10", reserved/renamed short ids)
no longer poison the max while long renames stay out of the sequence. Update
the tests to reflect the actual coverage.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:40:07 +00:00
hugocasaandClaude Opus 4.8 b5bd8245d8 fix: reject symlink traversal in job-dir path validation (#9713)
* fix: reject symlink traversal in job-dir path validation

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

* test: cover dangling symlink in job-dir path validation

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

* fix: close symlink-traversal bypass via in-bounds `..` in path check

Walk the normalized relative path instead of raw user components, so an
in-bounds `..` (e.g. `foo/../evil/payload`) can no longer drift the walk
past a planted symlink. Adds regression coverage.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:08:45 +00:00
Ruben FiszelandClaude Opus 4.8 de6192bec1 fix(frontend): highlight the runtime-chosen branch in flow graph viewer (#9755)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:37:56 +00:00
Ruben FiszelandClaude Opus 4.8 2a70ccc386 feat(frontend): show approval wait as a distinct segment in flow timeline (#9756)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:37:33 +00:00
centdixandClaude Opus 4.8 83cc5533ee feat: add /compact session chat command (#9764)
* feat: add session chat slash commands

* feat: add /compact session chat command

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

* fix: dedupe built-in commands against same-named workspace skills

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:33:55 +00:00
GuilhemandClaude Opus 4.8 42c5e7a3fc feat: scope AI sessions per workspace root with lifecycle reconcile (#9734)
* feat: scope AI sessions per workspace family with lifecycle reconcile

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

* refactor: centralize session reconcile trigger + extract pure lifecycle decision

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

* perf: remove unused workspace family index

* refactor: scope sessions by workspace root id, drop family_id column

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

* fix(sessions): preserve user-archived sessions when archiving their workspace

archiveSessionsForWorkspace tagged every session archivedByWorkspace, including ones the user had already archived by hand, so a later workspace unarchive auto-restored them. Skip already-archived sessions so only workspace-archived ones are tagged, matching decideSessionLifecycle.

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

* fix: archived-session banner with unarchive, suppress workspace-gone banner while archived

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

* fix: re-root sub-fork sessions on reconcile when an ancestor is deleted

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

* feat: group AI sessions by workspace family with show-all-workspaces filter

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

* chore: revert unrelated AIProviderPicker cosmetic changes

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

* fix: hide per-session unarchive when workspace is gone, show move/discard instead

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

* fix: GC attached files on lifecycle delete + reconcile on sidebar fork delete

Addresses Codex review: deleteSessionsForWorkspace/reconcile delete now GC linked files (deleteItemsForSession), matching deleteSession; sidebar deleteFork now reconciles so surviving child forks re-root off the deleted ancestor. Also de-flaked post-rehydrate reads in the IndexedDB tests via vi.waitFor.

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

* fix: don't strand user if post-delete reconcile throws; refresh stale warmSessions comment

Addresses auto-review P2s: wrap reconcileAfterWorkspaceChange in deleteFork so the parent switch + navigation always runs even on reconcile failure; correct the warmSessions comment which no longer holds under 'Show all workspaces'.

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

* fix: don't fail/strand fork archive+delete when client session cleanup throws

Addresses cubic P1/P2 on forks/compare: the workspace archive/delete is authoritative; wrap the best-effort session cleanup + reconcile so a local IndexedDB failure neither falsely reports failure nor blocks navigation away from the gone fork. Mirrors the SidebarContent fix.

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

* docs: drop drafting-history aside from reconcileAfterWorkspaceChange comment

Addresses auto-review P2: keep the refresh-before-reconcile invariant, drop the 'which they did inconsistently' narration per AGENTS.md (comments record constraints, not drafting history).

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

* fix: clean up sessions on fork-id reuse + make all workspace-mutation cleanup best-effort

Addresses Codex P1s: (1) CreateWorkspaceInner 'permanently delete existing fork' (id-reuse) now drops local sessions for that id so they don't resurface on the recreated fork; (2) workspace_settings archive/delete and SidebarContent child-delete loop + main delete now treat post-mutation session cleanup as best-effort, so a local IndexedDB failure can't strand the user or abort remaining deletes (matching the compare-page fix).

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

* fix: make fork-reuse session cleanup fire-and-forget (non-blocking)

Addresses cubic P2: don't await the best-effort cleanup so a slow IndexedDB op can't block the delete/reuse flow.

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

* fix: drop previous user's transient drafts on user change

Addresses Pi P1: hydrateSessions preserved transient (unsent) drafts across user changes, so user A's draft + its pending fork/workspace state bled into user B's list and got reused by createSession. onUserChange now drops transients when the email changes; reconcile (intra-user) still preserves them. Regression test added.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:41:18 +02:00
Ruben FiszelandClaude Opus 4.8 288318ac26 fix(apps): realign legacy raw-app drafts to raw_app draft kind (#9761)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:14:52 +02:00
GuilhemandClaude Opus 4.8 3d48ba7738 feat(frontend): add filter submenu to collapsed AI sessions popover (#9757)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:58:31 +02:00
Ruben FiszelandClaude Opus 4.8 fada673bb4 chore: bump uv to 0.11.24 in images and CI (#9759)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:58:08 +02:00
centdix 24b95e9fe1 feat: add session chat slash commands (#9748) 2026-06-24 13:43:40 +02:00
Ruben FiszelandClaude Opus 4.8 f5828780fd fix(backend): resolve folder_labels search_path on non-public (PG_SCHEMA) schemas (#9758)
* fix(backend): strip search_path=public from folder_labels migrations for non-public schema

The folder-labels migrations (20260610151334_folder_labels,
20260614075900_dedup_folder_labels) define `folder_labels(...)` with
`SET search_path = public` in their `CREATE FUNCTION` bodies. When Windmill
runs in a non-public schema (PG_SCHEMA), PostgreSQL validates the function
body against the `public` schema, where the `folder` table lacks the new
`labels` column, failing with `column "labels" does not exist`.

Add both migrations to OVERRIDDEN_MIGRATIONS, stripping the
`SET search_path = public` clause so the function inherits the current
search_path (which resolves the correct schema). Same regression and fix
pattern as PR #5400.

Fixes WIN-2093

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

* fix(backend): pin folder_labels search_path FROM CURRENT instead of stripping it

Keep the SECURITY DEFINER injection hardening while resolving the correct
schema on non-public (PG_SCHEMA) installs: FROM CURRENT snapshots the
migration connection's search_path at function creation time (public on
normal installs, the custom schema otherwise) instead of dropping the pin
and inheriting the caller's search_path at call time.

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

* fix(backend): repair migration to re-pin folder_labels search_path on applied instances

Instances that already applied the folder-labels migrations with the hardcoded
SET search_path = public have a folder_labels function pinned to public. On a
non-public (PG_SCHEMA) schema that reads the wrong folder table at runtime; the
OVERRIDDEN_MIGRATIONS fix only helps instances that have not applied them yet.

Add a CREATE OR REPLACE ... SET search_path FROM CURRENT migration that re-pins
the function to the migration connection's schema. No-op on public installs
(re-pins to public) and idempotent on already-correct ones.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:38:44 +00:00
GuilhemandClaude Opus 4.8 5e09c50171 fix(frontend): keep #content portal target present on AI-session route (#9754)
The global fork modal (and other modals) portal into `#content`, but that
element only existed in AiChatLayout's `!disableAi` branch. On the AI-session
route `disableAi` is true, so the `{:else}` branch rendered without `#content`,
and opening the fork modal there threw "No element found matching css selector:
#content". Give the else-branch container the same `id` so the portal target is
always present in this layout.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:21:17 +02:00
Ruben FiszelandClaude Opus 4.8 8912e21d15 perf(monitor): vacuum job_perms/job_result_stream right after each orphan sweep (#9753)
A customer's top-load query was the job_perms orphan sweep (cleanup_job_perms_orphaned:
6.9s mean, 41s max). The cost is discovery, not deletion (~2.1ms per row deleted): the
NOT EXISTS anti-join seq-scans the whole job_perms heap to find a few orphans, and that
scan tracks the heap's physical size. job_perms / job_result_stream_v2 get one row per job
and are drained only by these per-cycle sweeps, so they churn hard — but the bulk
vacuuming_tables() runs only ~hourly, so dead tuples bloat the heap between bulk vacuums.

Reclaim right after each sweep instead: VACUUM (SKIP_LOCKED) the swept table when it
deleted rows. Plain VACUUM (not FULL) takes only SHARE UPDATE EXCLUSIVE so concurrent job
creates/reads proceed; the visibility map skips unchanged pages so repeated runs are cheap;
SKIP_LOCKED means HA replicas don't pile up (one vacuums, the rest skip). Benchmarked ~7x:
a bloated 268MB job_perms heap swept in 35ms vs 5ms vacuumed. Chosen over an autovacuum
reloptions migration so the behavior is explicit and lives with the sweep it pairs with.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:20:53 +02:00
55bed4abcf perf(audit): adaptive timestamp floor for S3 audit-log export (#9752)
* perf(audit): adaptive timestamp floor for S3 audit-log export (ee)

EE change in windmill-ee-private (src/ee.rs); this OSS commit carries the regenerated
sqlx cache for the new oldest-in-flight query and bumps ee-repo-ref.txt to the EE branch.

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

* chore: update ee-repo-ref to ed89574be9117cda5e2d7d9de02cb5db066e93e3

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

Previous ee-repo-ref: 8a7f645c0a194a284fe19dd20dbe79dd0733dfdb

New ee-repo-ref: ed89574be9117cda5e2d7d9de02cb5db066e93e3

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-06-24 09:49:22 +02:00
GuilhemandClaude Opus 4.8 e98df38ac4 feat(apps): show raw-app fork diffs as per-file tree items (#9491)
* feat(apps): show raw-app fork diffs as per-file tree items

Raw-app diffs previously rendered as one big YAML diff of the whole
serialized app. This explodes a raw app into separate, independently
collapsible diff items — one per file, one per runnable, and an
app.yaml metadata item — that flow through the existing fork-diff
list, sidebar tree, search and count via composite paths
(<appPath>/<file>). Runnables render as script/flow rows (code shown
in a Content tab), and files get extension-specific icons reused from
the raw-app editor.

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

* chore: remove raw-app tree-diff plan doc from the branch

The implementation plan was an authoring aid, not product documentation; drop it so it doesn't ship in the PR.

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

* feat: present raw app as an app-headed folder in the diff tree

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

* fix: narrow RawAppFileItem in diff viewer branch (fixes svelte-check)

DiffRow.kind is a plain string so the kind check didn't narrow the union; assert the synthetic item. Also size-guard on the larger side's line count instead of the doubled total, and document normalizeRawApp's per-field value-wrapper precedence.

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

* style: single-line, lighter diff-tree rows for all item kinds

Add a singleLine mode to WorkspaceItemRow (summary ?? path on one line; DRY'd via a shared body snippet) and use it for every diff-tree leaf, so scripts/flows/triggers/resources/etc. match the raw-app header. Bump rows to py-1.5, force font-normal, and split colours: items in text-primary, folders in text-secondary.

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

* refactor: extract pure diffTree model from WorkspaceDiffDrawer

Move tree construction + keyboard-nav traversal + the folder-keying convention out of the 775-line component into a pure, generic, tested module (buildDiffTree → root/order/parentKeyOf/firstChildKeyOf). Parent and first-child come from a child→parent map built during construction, not from re-splitting a path at the call site, so a node's tree position and its nav parent can't drift — the class of bug behind the ArrowLeft regression. Deletes the forkDiffNav half-seam (its bug lived in the untested caller). 12 new unit tests cover order/parent/first-child incl. the storage-key-vs-friendly-path case.

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

* fix(apps): keep raw-app metadata flag + dedup runnables across path collisions

Addresses two P2 review nits (Codex/claude): (1) rawAppDiffToItems marked metadata by matching path==='app.yaml', so when a real file is named app.yaml the reserved app.yaml~2 metadata item lost its flag/full-YAML toggle — now parseRawAppDiff tags the entry with isMetadata and the items read the flag; (2) runnable composite leaves weren't deduped against real files, so a real file at runnables/<name> could produce a duplicate leaf — now reserved (slash-normalized) like parseRawAppDiff. +2 tests.

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

* fix(apps): dedup /app.yaml metadata collision + disambiguate synthetic row keys

Two follow-up P2s from Pi/Codex re-review of the prior fix: (1) parseRawAppDiff's collision set used raw file keys, so a real file /app.yaml (leading slash, which joinAppPath strips) still collided with the synthetic app.yaml leaf — now slash-normalized via a shared stripLeadingSlash, +test. (2) synthetic raw-app items (runnables rendered as script/flow) could share kind+path identity with a real workspace script/flow at <appPath>/runnables/<name>, causing duplicate {#each} keys and broken nav — itemKey now prefixes synthetic items (rawapp:).

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

* fix(apps): canonicalize raw-app file keys to dedup leading-slash collisions

Codex P2: a file keyed /App.tsx on one side and App.tsx on the other became two entries that joinAppPath collapsed to one composite path → duplicate row key. asFileMap now strips the leading slash so both sides resolve to one file. +test.

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

* perf(apps): lazy-mount per-file diff editors as they scroll into view

Exploding a raw app into N per-file rows mounted N Monaco DiffEditors at once (3 reviews flagged it). Each block's editor now mounts only when it scrolls within ~200px of the viewport (IntersectionObserver rooted on the scroll container), showing a light placeholder until then; mountedRows latches so it never unmounts on scroll-away. Verified: ~6 of 13 mount initially, the rest on scroll.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:23:14 +02:00
centdixandClaude Opus 4.8 c017f7f891 fix(frontend): show AI skills settings only when global mode enabled (#9747)
AI skills are only consumed by the GLOBAL chat mode's system prompt, and
global mode itself is dev-gated by isGlobalAiEnabled(). Gate the workspace
AI skills settings tab on the same flag so it isn't shown when the skills
can't be used, and add it to gate.ts's rip-out inventory.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:54:11 +02:00
Akira Yamazaki f1b5c43e02 chore: bump nixpkgs for uv 0.9.25 (#9749) 2026-06-24 07:53:43 +02:00
centdixandClaude Opus 4.8 250a05f544 fix(ai-chat): strip unclosed <summary> tag leaking into compaction summary (#9750)
* fix(ai-chat): strip unclosed <summary> tag leaking into compaction summary

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

* fix(ai-chat): strip analysis before matching summary to avoid scratchpad leak

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:53:14 +02:00
hugocasaandClaude Opus 4.8 043c2c05b7 fix: forbid superadmin job tokens from global user and token management (#9715)
* fix: forbid superadmin job tokens from global user and token management

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

* fix: extend superadmin job token guard to offboard and export routes

Apply forbid_superadmin_job_token to offboard_global_user and
export_global_users, the remaining global user-management routes that
were gated only by require_super_admin. Offboarding can delete a user
along with their tokens, password, invites and instance-group
membership, and export returns every user's password_hash, so both must
be unreachable by a superadmin job token.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 00:38:27 +02:00
centdix ae088fd032 stabilize global ai eval smoke path (#9745) 2026-06-24 00:31:14 +02:00
Ruben Fiszelandrubenfiszel 9e4cf139b1 chore(main): release 1.738.0 (#9735)
* chore(main): release 1.738.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-23 21:08:15 +00:00
Ruben FiszelandClaude Opus 4.8 cfb9f1dbc2 feat: render mermaid diagrams in chat code blocks (#9738)
* feat: render mermaid diagrams in chat code blocks

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

* fix: guard mermaid render against out-of-order async and transient streaming failures

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

* fix: only show mermaid diagram while it matches current source

Addresses Codex review: keeping the last good SVG through parse failures left a stale, mismatched diagram on screen when the source changed to something invalid. Tie the rendered SVG to the source that produced it and only display it while it still matches the current code, falling back to the raw source otherwise.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:02:10 +00:00
hugocasaandClaude Opus 4.8 cbf54d4eb4 fix: preserve fork parent linkage on workspace id change (#9716)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:54:07 +00:00
9793d01575 feat: add resource and infrastructure telemetry (#9737)
* feat(telemetry): disclose resource and infra usage stats

When minimal telemetry is disabled, the stats payload now includes resource
counts (workspaces, scripts per language, flows, workflows as code, low-code
and raw apps) and, on EE only, infrastructure info (container runtime,
database size, max connections, RDS detection).

Update the telemetry disclosure in instance settings accordingly: resource
counts are listed for both CE and EE; infra info is shown only on EE since it
is collected only there. Bump the EE ref and add the sqlx cache for the new
queries.

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

* feat(telemetry): expand EE infra disclosure and add sysinfo dep

Disclose the expanded EE infrastructure telemetry (deployment mode, host
OS/arch/CPU/memory, filesystem space, Postgres version and connection counts,
object storage backend, sandboxing and retention settings) in instance
settings. Add sysinfo as a windmill-common dependency for host memory and
filesystem stats, bump the EE ref, and add the sqlx cache for the new queries.

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

* refactor(telemetry): focus EE infra disclosure on wrapping platform

Drop the single-server host details (OS, arch, CPU, memory, filesystem) and
tuning config from the EE infra disclosure, and revert the sysinfo dependency
they required. Reflect managed-database-provider detection in place of the RDS
flag. Bump the EE ref and update the sqlx cache for the revised queries.

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

* refactor(telemetry): drop deployment mode and worker count from disclosure

Remove deployment mode and worker count from the EE infra disclosure to match
the backend, and bump the EE ref. They reflect only the node sending telemetry,
not the deployment topology.

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

* chore: update ee-repo-ref to 6d3301507db50818f1683dac3941d3e0cf1152a7

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

Previous ee-repo-ref: d30e7d18d14992598a97356d0ed13f7d5d585115

New ee-repo-ref: 6d3301507db50818f1683dac3941d3e0cf1152a7

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-06-23 20:53:12 +00:00
hugocasaandClaude Opus 4.8 24446e8009 fix: allow object storage test for non-super-admins, harden on cloud (#9739)
* fix: allow non-super-admin object storage test, harden SSRF surface on cloud

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

* fix: validate effective object storage host to close region/bucket SSRF bypass

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

* fix: validate gcs_base_url/token_uri in GCS service account key to close SSRF bypass

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

* fix: match url scheme case-insensitively in object storage host validation

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:50:40 +00:00
Ruben FiszelandClaude Opus 4.8 e90b2be8fa perf(monitor): skip protected prefix in retention delete via cross-batch watermark (WIN-2088) (#9744)
The expired-job retention loop re-scanned the same oldest rows on every batch. When
the oldest completed jobs are undeletable (children of a still-active root flow), the
ORDER BY completed_at ASC scan walked that protected prefix on each of the up-to-20
batches, doing a v2_job PK lookup per row — quadratic in prefix size (measured ~9s/batch,
~180s/cleanup-cycle on a 1.5M-row prefix).

Carry a completed_at watermark (max deleted) across batches and re-apply it as
completed_at >= floor so each batch resumes past the already-processed prefix. Also skip
the v2_job join entirely when no old root flow is active (the common case), since nothing
is protected then. Measured: subsequent batches 9000ms -> 159ms; empty-set path 154 -> 36ms.

The watermark only ever skips rows the current run already deleted, was protecting, or
skip-locked — all deferred to the next run, identical to the unbounded scan's row set
(verified: union of batched deletes == single delete, 0 diff). Mirrored in
windmill-api-settings log_cleanup.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:49:55 +00:00
29c67ced97 fix(frontend): debounce external code→Monaco sync in Editor (#9743)
* fix(frontend): debounce external code→Monaco sync in Editor

Make the external `code` prop → Monaco sync always-on and 500ms
debounced, replacing the opt-in `syncExternalCode` prop. Removes the
prop from the two inline rawscript call sites in FlowModuleComponent.

Includes temporary debug scaffolding (A→B executeEdits button and
console logs) for diagnosing successive-edit behavior.

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

* refactor(frontend): share alignCodeWithEditor + bump debounce to 800ms

Extract the full-range executeEdits sync into alignCodeWithEditor() and
reuse it from both setCode and the debounced external-code effect. Bump
the external-sync debounce 500ms -> 800ms. ScriptEditor now calls
editor.setCode when syncing external code in.

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

* nits

* nits

* Fix AI not seeing latest code

* remvoe debug button

* nit types

* nits

* Nits

* Check timeoutModel is undefined

* fix(frontend): suppress editor echo in external code sync to prevent typing clobber

* fix(frontend): cancel pending keystroke debounce in setCode to prevent clobber

* fix(frontend): preserve pending external code write in updateCode

* Revert "fix(frontend): preserve pending external code write in updateCode"

This reverts commit 731d877730.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Diego Imbert <diego@windmill.dev>
2026-06-23 20:45:02 +00:00
Ruben Fiszel 6dfccd9d88 frontend improvements 2026-06-23 17:52:22 +00:00
Diego Imbert fc797a35fe fix(ai-chat): Fix incorrect editor edits from ai chat #1 (#9741) 2026-06-23 15:23:06 +00:00
Diego ImbertandClaude Opus 4.8 11d0e65f3a fix(frontend): preserve editor content when closing instance settings drawer (#9740)
Closing the Instance settings drawer cleared the underlying script
editor. On unmount, SuperadminSettingsInner.removeHash() stripped the
`#superadmin-settings` hash with a SvelteKit `goto()`, and that
navigation re-fired the script editor page's path-reactive `$effect`,
reloading the script and wiping unsaved editor content.

Use `replaceState` to drop the hash without a navigation (matching the
existing RunForm.svelte pattern), guarded against router-teardown throws.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:31:41 +00:00
Ruben FiszelandClaude Opus 4.8 984ea728d9 fix: pipeline annotation false-positives from body comments (#9736)
* fix: reject pipeline `# tag` annotation false-positives on regular comments

`parse_pipeline_annotations` treats any comment line starting with
`# tag <text>` as a worker-tag annotation. In Python scripts, ordinary
English comments beginning with "# tag ..." were misinterpreted: values
over 50 chars failed the `script.tag` INSERT (varchar(50)), and shorter
ones silently overrode the script's worker tag.

Worker tags are single-word identifiers (e.g. `heavy`, `gpu`), so reject
any candidate that contains whitespace or exceeds 50 characters. Mirror
the same validation in the TS parity parser and add regression tests on
both sides.

Fixes WIN-2090

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

* fix: restrict pipeline annotation scan to the leading comment header

The root cause of the `# tag` false-positive is broader than the `tag`
keyword: `parse_pipeline_annotations` scanned every comment line in the
whole file, so any body comment matching an annotation grammar
(`on`, `freshness`, `tag`, `retry`, ...) was misinterpreted. The `tag`
case was the most visible because an over-length value crashed the
`script.tag` INSERT (varchar(50)).

Windmill's other comment-directive parsers (BashAnnotations::sandbox_image,
ssh_target) already scan only the leading comment header and stop at the
first line of real code. Align parse_pipeline_annotations (and its TS
mirror) with that convention: skip blank lines, break on the first
non-comment line. This eliminates body-comment false-positives for every
annotation, not just `tag`.

The `tag` whitespace/length guard from the previous commit is kept as
defense for prose that sits in the header itself.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:35:37 +00:00
138 changed files with 7690 additions and 2085 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
ENV GO_PATH=/usr/local/go/bin/go
# UV
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
ENV TZ=Etc/UTC
+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.9.25"
version: "0.11.24"
- uses: shivammathur/setup-php@v2
with:
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
node-version: "20"
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.9.25"
version: "0.11.24"
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## [1.738.0](https://github.com/windmill-labs/windmill/compare/v1.737.0...v1.738.0) (2026-06-23)
### Features
* add resource and infrastructure telemetry ([#9737](https://github.com/windmill-labs/windmill/issues/9737)) ([9793d01](https://github.com/windmill-labs/windmill/commit/9793d01575415963a89609a1baf2cd64f0d050cc))
* render mermaid diagrams in chat code blocks ([#9738](https://github.com/windmill-labs/windmill/issues/9738)) ([cfb9f1d](https://github.com/windmill-labs/windmill/commit/cfb9f1dbc23110ecf8f91bb3c8c81fc6e35dc09b))
### Bug Fixes
* **ai-chat:** Fix incorrect editor edits from ai chat [#1](https://github.com/windmill-labs/windmill/issues/1) ([#9741](https://github.com/windmill-labs/windmill/issues/9741)) ([fc797a3](https://github.com/windmill-labs/windmill/commit/fc797a35fe7885630c81453df0fc94769e73873a))
* allow object storage test for non-super-admins, harden on cloud ([#9739](https://github.com/windmill-labs/windmill/issues/9739)) ([24446e8](https://github.com/windmill-labs/windmill/commit/24446e80093ade349f7fbf65063d2d1cb5551c1e))
* **frontend:** debounce external code→Monaco sync in Editor ([#9743](https://github.com/windmill-labs/windmill/issues/9743)) ([29c67ce](https://github.com/windmill-labs/windmill/commit/29c67ced97bf2919584986f9d9eceb4337c34ad9))
* **frontend:** preserve editor content when closing instance settings drawer ([#9740](https://github.com/windmill-labs/windmill/issues/9740)) ([11d0e65](https://github.com/windmill-labs/windmill/commit/11d0e65f3af9a048bc1921bbdd3d676a07483a57))
* pipeline annotation false-positives from body comments ([#9736](https://github.com/windmill-labs/windmill/issues/9736)) ([984ea72](https://github.com/windmill-labs/windmill/commit/984ea728d98649b66b1cae899bdab9af3176caa7))
* preserve fork parent linkage on workspace id change ([#9716](https://github.com/windmill-labs/windmill/issues/9716)) ([cbf54d4](https://github.com/windmill-labs/windmill/commit/cbf54d4eb432638e27f67c4c8b879cbcc0291da3))
* prevent variable push from corrupting is_secret variables ([#9705](https://github.com/windmill-labs/windmill/issues/9705)) ([ba4b368](https://github.com/windmill-labs/windmill/commit/ba4b368706e95e22f346a10e5fe145b0795ac3f6))
### Performance Improvements
* **monitor:** skip protected prefix in retention delete via cross-batch watermark (WIN-2088) ([#9744](https://github.com/windmill-labs/windmill/issues/9744)) ([e90b2be](https://github.com/windmill-labs/windmill/commit/e90b2be8fade1eb78cd685890291f5a4553a6a10))
## [1.737.0](https://github.com/windmill-labs/windmill/compare/v1.736.0...v1.737.0) (2026-06-23)
+1 -1
View File
@@ -233,7 +233,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
ENV GO_PATH=/usr/local/go/bin/go
# Install UV
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
# Preinstall python runtimes to temp build location (will copy with world-writable perms later)
# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run
+1
View File
@@ -3,6 +3,7 @@
Create a draft Bun script at `f/evals/global/greet_user`.
It should take a string `name` input and return `Hello, ${name}!`.
Leave it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 10
validate:
+3
View File
@@ -212,6 +212,9 @@ describe("loadCases", () => {
},
],
});
expect(caseEntry?.initialPath).toContain(
"ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json"
);
expect(caseEntry?.toolExpect).toMatchObject({
requiredToolsUsed: ["write_script"],
forbiddenToolsUsed: ["deploy_workspace_item", "delete_workspace_item"],
@@ -0,0 +1,8 @@
{
"user": {
"username": "admin",
"is_admin": true,
"folders": ["evals"],
"folders_read": ["evals"]
}
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT SUM(pg_database_size(datname))::BIGINT AS \"v!\" FROM pg_database",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "074dd26f6427f4ff97e92c35163ad042144e656adcca56a4936a2eb196d3f48c"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT language AS \"language!: _\", COUNT(*)::BIGINT AS \"count!\"\n FROM script\n WHERE archived = false AND deleted = false AND kind = 'script'\n AND (auto_kind IS NULL OR auto_kind <> 'wac')\n GROUP BY language\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "language!: _",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang"
]
}
}
}
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
null
]
},
"hash": "11813108dbf6b104eba968c3609c74ac5a589542d3e76b1e756a82fb19d49ee8"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)::INT AS \"v!\" FROM pg_stat_activity",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "3b439ae7af0fcbb9df8e19faf84abf590e5e94898954711d57a602e6fd8a2f84"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM script WHERE archived = false AND deleted = false AND auto_kind = 'wac'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "3db1c61295c284725eef9e74a8aa2bc7822d263605a445f1f4a76e58e76a3e79"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "40a8cf5e87bb489fd172689e9a6f0f1075b878f9916145929b3cd3b1a53b777e"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT p.id AS \"id!\", p.deleted AS \"deleted!\"\n FROM workspace f\n JOIN workspace p ON p.id = f.parent_workspace_id\n WHERE f.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "deleted!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "42322020ff9cc7dd7ebafc1cb4122ba3d670cc36bdbc6451f29b8f22f8cff688"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_database_size(current_database())::BIGINT AS \"v!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "48a5df355a2bca557a3a541cf66c8e75790b7c3dd7845359019ff645a1f7c8bf"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT current_setting('max_connections')::INT AS \"v!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "49e1f5663eed128ed956c9a50bc73a85256c0a3e5a701cc13c944e66f6402617"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM flow WHERE archived = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "50490ff42fb1f2d78864d7b374d299bf8290c3b969b576cb185c8b5b0abb0265"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT current_setting('server_version_num')::INT AS \"v!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "56a98a07a2f6af4d694db05d57acbe0b55cc39d64f73a3c90c250ea670f9cdee"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT CASE WHEN (current_setting('is_superuser') = 'on'\n OR pg_has_role(current_user, 'pg_read_all_stats', 'USAGE'))\n AND NOT EXISTS (SELECT 1 FROM pg_prepared_xacts)\n THEN (SELECT min(xact_start) FROM pg_stat_activity WHERE xact_start IS NOT NULL)\n ELSE NULL END AS \"x\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "x",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "5d84f1ede2fbe09923a36d80d7c01699bf9968a02675f9f598c97a19c6df089b"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n COUNT(*) FILTER (WHERE av.raw_app = false)::BIGINT AS \"low_code!\",\n COUNT(*) FILTER (WHERE av.raw_app = true)::BIGINT AS \"raw!\"\n FROM app a\n JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "low_code!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "raw!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "60dc0f1fa17bd2946ba7ddf0c41fe58b8a53d071cdc83eca3092194b4a9c9174"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM workspace WHERE deleted = false AND id NOT LIKE 'wm-fork%'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "9170a350e1da0b33a421a119d4a5b86575c1be761de158ad664670e981524cbf"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($3::timestamptz IS NULL OR completed_at >= $3)\n ORDER BY completed_at ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id, completed_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "completed_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz"
]
},
"nullable": [
false,
false
]
},
"hash": "a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END\n FROM workspace WHERE id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "a54efa4a7466e61fd54d8fe293cb775225dcb430026cebe15ba4994ac636514d"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM pg_proc WHERE proname = 'aurora_version') AS \"aurora!\",\n EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'rds_superuser') AS \"rds!\",\n EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'cloudsqlsuperuser') AS \"cloudsql!\",\n EXISTS(SELECT 1 FROM pg_roles WHERE rolname IN ('azure_pg_admin', 'azuresu')) AS \"azure!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "aurora!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "rds!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "cloudsql!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "azure!",
"type_info": "Bool"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "a90e3a1d7c7c0dfb422f44b0ed599f681f5630c024ae9a437a301f149636b0db"
}
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id, completed_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "completed_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"UuidArray",
"Timestamptz"
]
},
"nullable": [
false,
false
]
},
"hash": "c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"UuidArray"
]
},
"nullable": [
false
]
},
"hash": "fbe3a876efd1253d2ef086b03366b2bd117ceb6bc152d2abcd45850ff6aecff9"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT req.id AS \"id!\",\n (CASE\n WHEN usr.email IS NULL THEN 'deleted'\n WHEN workspace.deleted THEN 'archived'\n ELSE 'active'\n END) AS \"status!\"\n FROM unnest($1::text[]) AS req(id)\n LEFT JOIN workspace ON workspace.id = req.id\n LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "status!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"TextArray",
"Text"
]
},
"nullable": [
null,
null
]
},
"hash": "fc4583d1570f3a2a428bb28390ca72e61719fae68aa4b42730f9076f3bd97441"
}
+78 -78
View File
@@ -13735,7 +13735,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-nats",
@@ -13817,7 +13817,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"async-stream",
"async-trait",
@@ -13850,7 +13850,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -13863,7 +13863,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"argon2",
@@ -14001,7 +14001,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14024,7 +14024,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14037,7 +14037,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14063,7 +14063,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14073,7 +14073,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14090,7 +14090,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14112,7 +14112,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14135,7 +14135,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14151,7 +14151,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14172,7 +14172,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14193,7 +14193,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14207,7 +14207,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14242,7 +14242,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14267,7 +14267,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -14285,7 +14285,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14307,7 +14307,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14327,7 +14327,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14364,7 +14364,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14392,7 +14392,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"lazy_static",
"serde",
@@ -14404,7 +14404,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -14429,7 +14429,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14443,7 +14443,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14476,7 +14476,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"chrono",
"lazy_static",
@@ -14490,7 +14490,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14509,7 +14509,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -14611,7 +14611,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -14630,7 +14630,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"regex",
"serde",
@@ -14645,7 +14645,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -14669,7 +14669,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"futures",
@@ -14686,7 +14686,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14702,7 +14702,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14723,7 +14723,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14754,7 +14754,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -14779,7 +14779,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-stream",
@@ -14813,7 +14813,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"futures",
@@ -14831,7 +14831,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14840,7 +14840,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14852,7 +14852,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14864,7 +14864,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"gosyn",
@@ -14876,7 +14876,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14888,7 +14888,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14900,7 +14900,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -14911,7 +14911,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14922,7 +14922,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14934,7 +14934,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -14945,7 +14945,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -14967,7 +14967,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14979,7 +14979,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14993,7 +14993,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15010,7 +15010,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15023,7 +15023,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde",
@@ -15035,7 +15035,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15053,7 +15053,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15069,7 +15069,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15085,7 +15085,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde",
@@ -15096,7 +15096,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15134,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"const_format",
@@ -15173,7 +15173,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15184,7 +15184,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15218,7 +15218,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15242,7 +15242,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15275,7 +15275,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15308,7 +15308,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15328,7 +15328,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15362,7 +15362,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15398,7 +15398,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15421,7 +15421,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15445,7 +15445,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15469,7 +15469,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15504,7 +15504,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15532,7 +15532,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15557,7 +15557,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"bitflags 2.13.0",
@@ -15576,7 +15576,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -15686,7 +15686,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.737.0"
version = "1.738.0"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.737.0"
version = "1.738.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
ac1f6f666f36141cb6ba6f8eaa614821a90464ad
ed89574be9117cda5e2d7d9de02cb5db066e93e3
@@ -0,0 +1,3 @@
-- No-op: this migration only re-pins the function's search_path. Reverting would
-- mean restoring the hardcoded `SET search_path = public`, which is the very bug
-- this repairs, so there is nothing to undo.
@@ -0,0 +1,22 @@
-- Repair instances that already applied the folder-labels migrations while the
-- function hardcoded `SET search_path = public`. On a non-public schema (PG_SCHEMA)
-- the function was pinned to `public`, so at runtime it read the wrong `folder`
-- table (or a stray public.folder) instead of the workspace's real one.
--
-- `FROM CURRENT` snapshots the migration connection's search_path (the actual
-- Windmill schema) into the function, keeping the SECURITY DEFINER injection
-- hardening. On public-schema installs this re-pins to `public`, i.e. a no-op.
-- Idempotent: redefining with the same body is harmless on already-correct installs.
CREATE OR REPLACE FUNCTION folder_labels(w_id text, item_path text) RETURNS text[]
LANGUAGE sql STABLE SECURITY DEFINER SET search_path FROM CURRENT AS $$
SELECT (
SELECT array_agg(l ORDER BY first_ord)
FROM (
SELECT u.l, min(u.ord) AS first_ord
FROM unnest(f.labels) WITH ORDINALITY AS u(l, ord)
GROUP BY u.l
) deduped
)
FROM folder f
WHERE f.workspace_id = w_id AND item_path LIKE 'f/%' AND f.name = split_part(item_path, '/', 2)
$$;
@@ -0,0 +1,3 @@
-- Irreversible data backfill: once a raw app's draft is retyped to 'raw_app' it
-- is indistinguishable from one saved as 'raw_app' by the per-kind code, so the
-- original typ='app' state cannot be reconstructed. No-op on revert.
@@ -0,0 +1,35 @@
-- The pre-per-user `DRAFT_TYPE` enum had only ('script','flow','app'): a raw
-- app's draft was therefore stored as typ='app'. The new model splits app vs
-- raw_app into distinct draft kinds chosen from the deployed app's `raw_app`
-- flag, so a raw app's pre-migration draft is invisible to the per-kind lookups
-- (editor overlay, migrate-legacy, get-for-user), which all query typ='raw_app'.
-- Realign every such draft (any owner, including the legacy NULL-email row) to
-- 'raw_app' when the deployed app at that path is a raw app.
-- Drop, don't retype, a stale 'app' row when a 'raw_app' draft already exists
-- for the same owner (the newer 'raw_app' row, saved with the per-kind code, is
-- authoritative) — retyping would collide on the draft_pkey_with_user /
-- draft_pkey_legacy partial unique indexes over (workspace_id, path, typ, email).
DELETE FROM draft d
USING app a
JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]
WHERE d.typ = 'app'
AND a.workspace_id = d.workspace_id
AND a.path = d.path
AND av.raw_app IS TRUE
AND EXISTS (
SELECT 1 FROM draft d2
WHERE d2.workspace_id = d.workspace_id
AND d2.path = d.path
AND d2.typ = 'raw_app'
AND d2.email IS NOT DISTINCT FROM d.email
);
UPDATE draft d
SET typ = 'raw_app'
FROM app a
JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]
WHERE d.typ = 'app'
AND a.workspace_id = d.workspace_id
AND a.path = d.path
AND av.raw_app IS TRUE;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6272,7 +6272,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"convert_case",
"serde",
@@ -6293,7 +6293,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6305,7 +6305,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6317,7 +6317,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6329,7 +6329,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6341,7 +6341,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6353,7 +6353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6364,7 +6364,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6375,7 +6375,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6387,7 +6387,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6398,7 +6398,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6420,7 +6420,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6432,7 +6432,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6538,7 +6538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6570,7 +6570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde",
@@ -6581,7 +6581,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.737.0"
version = "1.738.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
@@ -487,9 +487,13 @@ fn parse_kv_opts(s: &str) -> BTreeMap<String, String> {
out
}
// Scan raw source for pipeline annotations. Language-agnostic: any line
// whose first non-whitespace tokens are a comment prefix (`//`, `#`, or
// `--`) followed by one of the recognized keywords:
// Scan the leading comment header for pipeline annotations. Only the
// contiguous block of comment lines at the top of the file is considered
// (blank lines tolerated, scan stops at the first line of actual code) so
// that ordinary comments in the body can't false-positive as annotations.
// Language-agnostic: any header line whose first non-whitespace tokens are
// a comment prefix (`//`, `#`, or `--`) followed by one of the recognized
// keywords:
// - `pipeline` → opt-in marker (must be alone on the line)
// - `on <trigger-spec>` → asset / native trigger edge (including
// the marker-only `on schedule` form)
@@ -527,6 +531,9 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
for raw_line in code.lines() {
let line = raw_line.trim_start();
if line.is_empty() {
continue;
}
let rest = if let Some(r) = line.strip_prefix("//") {
r
} else if let Some(r) = line.strip_prefix("--") {
@@ -534,7 +541,11 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
} else if let Some(r) = line.strip_prefix('#') {
r
} else {
continue;
// Annotations live in the leading comment header. Stop at the first
// line of actual code so comments inside the body (e.g. a regular
// `# tag ...` prose comment) can't false-positive as annotations.
// Mirrors BashAnnotations::sandbox_image / ssh_target.
break;
};
let rest = rest.trim_start();
@@ -584,7 +595,14 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
if let Some(after_kw) = consume_keyword(rest, "tag") {
let name = after_kw.trim();
if !name.is_empty() && out.tag.is_none() {
// Worker tags are single-word identifiers (e.g. `heavy`, `gpu`).
// A value with whitespace or beyond the `script.tag` column width
// is almost certainly a regular comment starting with "# tag ...".
if !name.is_empty()
&& !name.contains(char::is_whitespace)
&& name.len() <= 50
&& out.tag.is_none()
{
out.tag = Some(name.to_string());
}
continue;
@@ -1074,6 +1092,56 @@ mod pipeline_annotation_tests {
assert!(out.tag.is_none());
}
#[test]
fn tag_with_whitespace_is_skipped() {
// A regular English comment starting with "# tag " must not be
// mistaken for a worker-tag annotation (worker tags are single words).
let out =
parse_pipeline_annotations("# tag this function so we remember to refactor it later");
assert!(out.tag.is_none());
}
#[test]
fn tag_too_long_is_skipped() {
let long = "x".repeat(51);
let out = parse_pipeline_annotations(&format!("// tag {long}"));
assert!(out.tag.is_none());
}
#[test]
fn annotations_in_body_are_ignored() {
// Only the leading comment header is scanned. A regular `# tag ...`
// prose comment buried in the body — the WIN-2090 false-positive that
// crashed the `script.tag` INSERT — must not be treated as an
// annotation once real code has started.
let code = concat!(
"import pandas as pd\n",
"\n",
"def main():\n",
" # tag each row with its source so downstream steps can filter\n",
" # on s3://should/not/parse\n",
" return pd.DataFrame()\n",
);
let out = parse_pipeline_annotations(code);
assert!(out.tag.is_none());
assert!(out.triggers.is_empty());
}
#[test]
fn header_allows_blank_lines_before_code() {
// Blank lines (e.g. after a shebang) don't end the header; the first
// line of real code does.
let code = concat!(
"#!/usr/bin/env python\n",
"\n",
"# tag heavy\n",
"import os\n",
"# tag light\n",
);
let out = parse_pipeline_annotations(code);
assert_eq!(out.tag.as_deref(), Some("heavy"));
}
#[test]
fn retry_count_only() {
let out = parse_pipeline_annotations("// retry 3");
+108 -33
View File
@@ -1324,6 +1324,9 @@ pub async fn delete_expired_items(db: &DB) -> () {
let cleanup_start = Instant::now();
let mut total_deleted = 0u64;
let mut batch_num = 0i32;
// Watermark carried across batches so each one resumes after the rows the previous batch
// already processed instead of re-scanning the (potentially undeletable) oldest prefix.
let mut completed_at_floor: Option<DateTime<Utc>> = None;
// Process batches until no more expired jobs or max batches reached
loop {
@@ -1336,14 +1339,17 @@ pub async fn delete_expired_items(db: &DB) -> () {
}
// Each batch runs in its own transaction to avoid long-running locks
let batch_result = delete_expired_jobs_batch(db, job_retention_secs, batch_size).await;
let batch_result =
delete_expired_jobs_batch(db, job_retention_secs, batch_size, completed_at_floor)
.await;
match batch_result {
Ok(deleted_count) => {
Ok((deleted_count, max_completed_at)) => {
if deleted_count == 0 {
// No more expired jobs to delete
break;
}
completed_at_floor = max_completed_at.or(completed_at_floor);
total_deleted += deleted_count as u64;
batch_num += 1;
}
@@ -1510,12 +1516,20 @@ pub async fn check_expiring_tokens(db: &DB) {
/// Delete a batch of expired jobs with LIMIT and SKIP LOCKED for high-scale environments.
/// Uses a single transaction per batch to minimize lock duration.
/// Returns the number of jobs deleted in this batch.
///
/// `completed_at_floor` is the watermark from the previous batch in the same cleanup run (the
/// max `completed_at` it deleted); pass `None` for the first batch. It is re-applied as
/// `completed_at >= floor` so the scan resumes past the rows already processed instead of
/// re-walking them (see the inline comment on the DELETE for why this matters).
///
/// Returns `(jobs deleted in this batch, max completed_at deleted)`. The caller feeds the
/// returned watermark back in as `completed_at_floor` for the next batch.
async fn delete_expired_jobs_batch(
db: &DB,
job_retention_secs: i64,
batch_size: i64,
) -> error::Result<usize> {
completed_at_floor: Option<DateTime<Utc>>,
) -> error::Result<(usize, Option<DateTime<Utc>>)> {
let mut tx = db.begin().await?;
// Fetch active ROOT job IDs that started before the retention period. We only care about
@@ -1531,34 +1545,70 @@ async fn delete_expired_jobs_batch(
.fetch_all(&mut *tx)
.await?;
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas
// ORDER BY completed_at ensures we delete oldest jobs first.
// Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than
// `!= ALL($3)`: the subquery form lets the planner build a one-time hashed
// SubPlan and apply it as a filter on the ordered index scan, giving O(1)
// membership per candidate instead of a per-row linear array scan (which
// degrades sharply when many root jobs are active). The `u IS NOT NULL` guard
// sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids).
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
)
RETURNING id",
job_retention_secs,
batch_size,
&active_root_job_ids
)
.fetch_all(&mut *tx)
.await?;
// `completed_at_floor` is a watermark carried across batches within a cleanup run: it is the
// max(completed_at) deleted by the previous batch. Re-applying it as `completed_at >= floor`
// lets each batch resume after the rows the previous batch already processed instead of
// re-scanning them. This matters when the oldest rows are undeletable (children of a
// still-active root flow): without the floor the `ORDER BY completed_at ASC` scan walks that
// same protected prefix on every batch, turning a cleanup run quadratic in prefix size.
// Floor only ever skips rows the current run already deleted, was protecting, or skip-locked —
// all correctly deferred to the next run, identical to the unbounded scan's semantics.
//
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas; ORDER BY completed_at
// deletes oldest jobs first.
let (deleted_jobs, max_completed_at) = if active_root_job_ids.is_empty() {
// Common case: no old root flow is still running, so nothing is protected and the
// v2_job join (a PK lookup per candidate) is pure overhead — skip it entirely.
let rows = sqlx::query!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT id FROM v2_job_completed
WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval
AND ($3::timestamptz IS NULL OR completed_at >= $3)
ORDER BY completed_at ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
)
RETURNING id, completed_at",
job_retention_secs,
batch_size,
completed_at_floor,
)
.fetch_all(&mut *tx)
.await?;
let max = rows.iter().map(|r| r.completed_at).max();
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
} else {
// Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than `!= ALL($3)`:
// the subquery form lets the planner build a one-time hashed SubPlan and apply it as a
// filter on the ordered index scan, giving O(1) membership per candidate instead of a
// per-row linear array scan (which degrades sharply when many root jobs are active). The
// `u IS NOT NULL` guard sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids).
let rows = sqlx::query!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
)
RETURNING id, completed_at",
job_retention_secs,
batch_size,
&active_root_job_ids,
completed_at_floor,
)
.fetch_all(&mut *tx)
.await?;
let max = rows.iter().map(|r| r.completed_at).max();
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
};
let deleted_count = deleted_jobs.len();
@@ -1618,7 +1668,7 @@ async fn delete_expired_jobs_batch(
tx.commit().await?;
Ok(deleted_count)
Ok((deleted_count, max_completed_at))
}
async fn delete_log_files_from_disk_and_store(
@@ -4379,9 +4429,32 @@ RETURNING key,job_id
// Per-statement cap keeps each delete short and lock-light; the per-cycle batch
// cap bounds total work per monitor iteration so monitor_db stays responsive.
// A large backlog drains across several iterations rather than one long delete.
//
// These sweeps anti-join the whole table to find orphans, so their cost tracks the heap's
// physical size. job_perms / job_result_stream_v2 are high-churn (one row per job, deleted
// here), so their bloat — not the query shape — is what makes the sweep slow. These sweeps run
// every monitor cycle, but the bulk vacuuming_tables() runs only ~hourly, so dead tuples pile
// up between bulk vacuums; each sweep VACUUMs its own table right after deleting (see below) to
// keep the heap near the live working set. The outer `ctid IN (SELECT ... LIMIT)` is
// deliberate: a `job_id IN (...)` rewrite adds a second scan/probe for the delete and
// benchmarks slower, so don't "simplify" it.
const ORPHAN_CLEANUP_BATCH_SIZE: u64 = 100_000;
const ORPHAN_CLEANUP_MAX_BATCHES: usize = 10;
// Reclaim the dead tuples a sweep just created so the next sweep's anti-join scans a lean heap
// instead of a bloated one. Plain VACUUM (not FULL) only takes SHARE UPDATE EXCLUSIVE, so
// concurrent reads/writes (every job create touches job_perms) keep running, and the visibility
// map lets it skip unchanged pages so repeated runs are cheap. SKIP_LOCKED means HA replicas
// don't pile up: one vacuums, the rest skip rather than queue behind it.
async fn vacuum_after_sweep(db: &DB, table: &str) {
if let Err(e) = sqlx::query(&format!("VACUUM (SKIP_LOCKED) {table}"))
.execute(db)
.await
{
tracing::warn!("Error vacuuming {table} after orphan cleanup: {e:?}");
}
}
async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
let mut total: u64 = 0;
for _ in 0..ORPHAN_CLEANUP_MAX_BATCHES {
@@ -4404,6 +4477,7 @@ async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
if total > 0 {
tracing::info!("Cleaned up {total} orphaned job_perms rows");
vacuum_after_sweep(db, "job_perms").await;
}
Ok(())
}
@@ -4435,6 +4509,7 @@ async fn cleanup_job_result_stream_orphaned_jobs(db: &DB) -> error::Result<()> {
if total > 0 {
tracing::info!("Cleaned up {total} orphaned job_result_stream_v2 rows");
vacuum_after_sweep(db, "job_result_stream_v2").await;
}
Ok(())
}
+206
View File
@@ -0,0 +1,206 @@
//! A WM_TOKEN (job JWT) running as a superadmin must not be able to perform
//! global user/token management — promotion, password reset, user creation,
//! token creation/impersonation, offboarding, or exporting the user table.
//! A non-admin `wm_deployers` member can mint
//! such a token implicitly via an app/flow `on_behalf_of`, so trusting it would
//! let them establish *persistent* superadmin. A real superadmin who needs this
//! from a script must use a dedicated superadmin API token (which only a real
//! superadmin can create), not `$WM_TOKEN`.
//!
//! The fixture provides `test@windmill.dev` (instance superadmin, token
//! `SECRET_TOKEN`) and `test2@windmill.dev` (non-superadmin, `SECRET_TOKEN_2`).
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::auth::create_jwt_token;
use windmill_common::db::Authed;
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
/// Mint a WM_TOKEN: an internally-signed job JWT (note the `job_id` claim) for
/// `email`, exactly as a running app/flow job is issued.
async fn wm_token(email: &str, is_admin: bool) -> String {
let authed = Authed {
email: email.to_string(),
username: "runner".to_string(),
is_admin,
is_operator: false,
groups: vec![],
folders: vec![],
scopes: None,
token_prefix: None,
};
create_jwt_token(
authed,
"test-workspace",
3600,
Some(uuid::Uuid::new_v4()),
Some("app".to_string()),
None,
None,
)
.await
.expect("mint wm_token")
}
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_cannot_manage_superadmin_users(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// The server decodes WM_TOKENs with the same in-process JWT secret, so
// setting it once lets us mint a valid one below.
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/users");
// A superadmin-capable WM_TOKEN — the exact thing a deployer obtains via an
// app on_behalf_of pointed at a superadmin.
let sa_wm = wm_token("test@windmill.dev", true).await;
// 1. Cannot mint a (superadmin) token.
let resp = authed(client().post(format!("{base}/tokens/create")), &sa_wm)
.json(&json!({}))
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not create tokens: {}",
resp.text().await?
);
// 2. Cannot impersonate (mint a token as another user).
let resp = authed(client().post(format!("{base}/tokens/impersonate")), &sa_wm)
.json(&json!({ "impersonate_email": "test2@windmill.dev" }))
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not impersonate: {}",
resp.text().await?
);
// 3. Cannot promote a user to superadmin.
let resp = authed(
client().post(format!("{base}/update/test2@windmill.dev")),
&sa_wm,
)
.json(&json!({ "is_super_admin": true }))
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not promote users: {}",
resp.text().await?
);
// 4. Cannot reset its own (the superadmin's) password.
let resp = authed(client().post(format!("{base}/setpassword")), &sa_wm)
.json(&json!({ "password": "hunter2" }))
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not reset passwords: {}",
resp.text().await?
);
// 4b. Cannot delete a user.
let resp = authed(
client().delete(format!("{base}/delete/test2@windmill.dev")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not delete users: {}",
resp.text().await?
);
// 4c. Cannot change a user's login type.
let resp = authed(
client().post(format!("{base}/set_login_type/test2@windmill.dev")),
&sa_wm,
)
.json(&json!({ "login_type": "password" }))
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not change login type: {}",
resp.text().await?
);
// 4d. Cannot offboard a global user (deletes user, tokens, password, invites,
// instance-group membership and reassigns their assets).
let resp = authed(
client().post(format!("{base}/offboard/test2@windmill.dev")),
&sa_wm,
)
.json(&json!({}))
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not offboard users: {}",
resp.text().await?
);
// 4e. Cannot export the global user table (leaks every user's password_hash).
let resp = authed(client().get(format!("{base}/export")), &sa_wm)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not export global users: {}",
resp.text().await?
);
// 5. Escape hatch / no false positive: a real superadmin API token
// (SECRET_TOKEN, no job_id) can still create tokens.
let resp = authed(
client().post(format!("{base}/tokens/create")),
"SECRET_TOKEN",
)
.json(&json!({ "label": "ci" }))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"a real superadmin token must still create tokens: {}",
resp.text().await?
);
// 6. No collateral: a non-superadmin WM_TOKEN can still create its own
// token — the guard only fires for superadmin-capable job tokens.
let user_wm = wm_token("test2@windmill.dev", false).await;
let resp = authed(client().post(format!("{base}/tokens/create")), &user_wm)
.json(&json!({ "label": "from-script" }))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"non-superadmin WM_TOKEN must still create its own token: {}",
resp.text().await?
);
Ok(())
}
+27
View File
@@ -205,6 +205,33 @@ pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> {
}
}
/// Forbid sensitive global user/token management when authenticated as a
/// superadmin *via a job token* (`WM_TOKEN`).
///
/// A `WM_TOKEN`'s identity is derived from an app/flow `on_behalf_of`, which a
/// non-admin `wm_deployers` member can point at a superadmin. Trusting it for
/// these operations would let them establish *persistent* superadmin (promote a
/// user, reset a superadmin's password, mint a superadmin token, ...). `job_id`
/// is set only for `WM_TOKEN`s; regular session/API tokens have it `None`, so a
/// real superadmin who needs this from a script uses a dedicated superadmin API
/// token (which only a real superadmin can create) instead of `$WM_TOKEN`.
pub async fn forbid_superadmin_job_token(
db: &DB,
email: &str,
job_id: Option<uuid::Uuid>,
) -> error::Result<()> {
if job_id.is_some() && is_super_admin_email(db, email).await? {
return Err(Error::NotAuthorized(
"This operation cannot be performed with a job token ($WM_TOKEN) that runs as a \
superadmin. If a script genuinely needs to do this, create a dedicated superadmin \
token from the User settings drawer (the 'Tokens' section), store it as a secret, \
and use that token explicitly instead of $WM_TOKEN."
.to_owned(),
));
}
Ok(())
}
pub fn check_scopes<F>(authed: &ApiAuthed, required: F) -> error::Result<()>
where
F: FnOnce() -> String,
@@ -646,6 +646,20 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.unwrap();
assert_eq!(resp.json::<bool>().await?, true);
// Regression: changing a fork's workspace id must preserve its parent
// linkage. Dropping it leaves a wm-fork- workspace with no parent — a
// "fork of nothing" that can no longer be compared or merged.
let parent: Option<String> =
sqlx::query_scalar("SELECT parent_workspace_id FROM workspace WHERE id = $1")
.bind("wm-fork-renamed")
.fetch_one(&db)
.await?;
assert_eq!(
parent.as_deref(),
Some("new-test-ws"),
"renamed fork must keep its parent_workspace_id"
);
// --- create_fork over an existing (active) workspace id: clear 400, not a raw SQL 500 ---
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
.json(&json!({
+391 -35
View File
@@ -256,52 +256,300 @@ pub async fn test_s3_bucket(
use bytes::Bytes;
use futures::StreamExt;
require_super_admin(&db, &authed.email).await?;
// The probe executes on the API server itself. On multi-tenant Cloud that is a shared control
// plane, so we constrain untrusted callers to remove the SSRF / credential-exfiltration /
// local-filesystem surface (see validate_object_storage_test). On self-hosted instances the
// object store usually lives on the local/private network and all authenticated users are
// trusted, so testing there stays unrestricted. Super admins keep the unrestricted path too.
let is_super_admin = is_super_admin_email(&db, &authed.email).await?;
let restrict = !is_super_admin && *CLOUD_HOSTED;
if restrict {
validate_object_storage_test(&test_s3_bucket).await?;
}
let client = build_object_store_from_settings(test_s3_bucket, Some(&db))
.await?
.store;
let mut list = client.list(Some(
&windmill_object_store::object_store_reexports::Path::from("".to_string()),
));
let first_file = list.next().await;
if first_file.is_some() {
if let Err(e) = first_file.as_ref().unwrap() {
tracing::error!("error listing bucket: {e:#}");
error::Error::internal_err(format!("Failed to list files in blob storage: {e:#}"));
let run = async {
let mut list = client.list(Some(
&windmill_object_store::object_store_reexports::Path::from("".to_string()),
));
let first_file = list.next().await;
if first_file.is_some() {
if let Err(e) = first_file.as_ref().unwrap() {
tracing::error!("error listing bucket: {e:#}");
error::Error::internal_err(format!("Failed to list files in blob storage: {e:#}"));
}
tracing::info!("Listed files: {:?}", first_file.unwrap());
} else {
tracing::info!("No files in blob storage");
}
tracing::info!("Listed files: {:?}", first_file.unwrap());
let path = windmill_object_store::object_store_reexports::Path::from(format!(
"/test-s3-bucket-{uuid}",
uuid = uuid::Uuid::new_v4()
));
tracing::info!("Testing blob storage at path: {path}");
client
.put(
&path,
windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"),
)
.await
.map_err(|e| anyhow::anyhow!("error writing file to {path}: {e:#}"))?;
let content = client
.get(&path)
.await
.map_err(to_anyhow)?
.bytes()
.await
.map_err(to_anyhow)?;
if content != Bytes::from_static(b"hello") {
return Err(error::Error::internal_err(
"Failed to read back from blob storage".to_string(),
));
}
client.delete(&path).await.map_err(to_anyhow)?;
Ok::<String, error::Error>("Tested blob storage successfully".to_string())
};
if restrict {
// The object-store client is built with timeouts disabled, so a malicious endpoint could
// otherwise hold the API server connection open indefinitely.
tokio::time::timeout(Duration::from_secs(15), run)
.await
.map_err(|_| {
error::Error::internal_err("Object storage connectivity test timed out".to_string())
})?
} else {
tracing::info!("No files in blob storage");
run.await
}
}
// Hardening for the object-storage connectivity test by an untrusted (non-super-admin) caller on
// Cloud. The probe runs on the shared API server, so without these constraints an authenticated
// user could coerce the server into connecting to arbitrary internal endpoints (SSRF), signing
// requests with the instance role (credential exfiltration), or reading/writing the server's local
// disk (filesystem object store).
#[cfg(feature = "parquet")]
async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Result<()> {
fn non_empty(opt: &Option<String>) -> bool {
opt.as_ref().is_some_and(|s| !s.is_empty())
}
let path = windmill_object_store::object_store_reexports::Path::from(format!(
"/test-s3-bucket-{uuid}",
uuid = uuid::Uuid::new_v4()
));
tracing::info!("Testing blob storage at path: {path}");
client
.put(
&path,
windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"),
)
// Reject backends that rely on the server's identity or local filesystem, require explicit
// credentials for the rest (so the server never falls back to its own ambient credentials), and
// resolve the host the client will actually connect to. We derive the *effective* endpoint here
// — mirroring build_*_from_settings: the region/account-derived default and the virtual-hosted
// bucket prefix — rather than only validating a caller-supplied `endpoint`, so caller-controlled
// `region`/`account_name`/`bucket` cannot smuggle an internal host past the check (e.g. an empty
// endpoint with region = "@169.254.169.254/" otherwise resolves to the cloud metadata service).
let effective_endpoint: Option<String> = match settings {
ObjectSettings::Filesystem(_) => {
return Err(error::Error::NotAuthorized(
"Testing a local filesystem object store requires a super admin".to_string(),
));
}
ObjectSettings::AwsOidc(_) => {
return Err(error::Error::NotAuthorized(
"Testing OIDC-based object storage requires a super admin".to_string(),
));
}
ObjectSettings::S3(s3) => {
if !(non_empty(&s3.access_key) && non_empty(&s3.secret_key)) {
return Err(error::Error::NotAuthorized(
"Testing S3 storage without explicit credentials requires a super admin"
.to_string(),
));
}
let region = s3
.region
.clone()
.filter(|r| !r.is_empty())
.or_else(|| std::env::var("AWS_REGION").ok().filter(|r| !r.is_empty()))
.unwrap_or_else(|| "us-east-1".to_string());
let raw_endpoint = s3
.endpoint
.clone()
.filter(|e| !e.is_empty())
.or_else(|| std::env::var("S3_ENDPOINT").ok().filter(|e| !e.is_empty()))
.unwrap_or_else(|| format!("s3.{region}.amazonaws.com"));
Some(windmill_object_store::render_endpoint(
raw_endpoint,
!s3.allow_http.unwrap_or(true),
s3.port,
s3.path_style,
s3.bucket.clone().unwrap_or_default(),
))
}
ObjectSettings::Azure(azure) => {
if !non_empty(&azure.access_key) {
return Err(error::Error::NotAuthorized(
"Testing Azure storage without an explicit access key requires a super admin"
.to_string(),
));
}
Some(
azure
.endpoint
.clone()
.filter(|e| !e.is_empty())
.unwrap_or_else(|| format!("{}.blob.core.windows.net", azure.account_name)),
)
}
ObjectSettings::Gcs(gcs) => {
if gcs.service_account_key.is_empty() {
return Err(error::Error::NotAuthorized(
"Testing GCS storage without a service account key requires a super admin"
.to_string(),
));
}
// The service-account-key JSON can override the data-plane URL (`gcs_base_url`) and the
// OAuth token endpoint (`token_uri`); the GCS client connects to whatever they point at.
// Validate every http(s) URL embedded in the key. When none override it, the host stays
// the public storage.googleapis.com, so no further check is needed.
if let Ok(serde_json::Value::Object(map)) =
serde_json::from_str::<serde_json::Value>(&gcs.service_account_key)
{
for value in map.values() {
if let Some(url) = value.as_str() {
// Match how the URL parser reads the value: leading whitespace/control is
// ignored and the scheme is case-insensitive.
let url =
url.trim_start_matches(|c: char| c.is_whitespace() || c.is_control());
if strip_http_scheme(url).is_some() {
validate_public_endpoint(url).await?;
}
}
}
}
None
}
};
// Block non-public network targets (internal services, cloud metadata, loopback, ...).
if let Some(endpoint) = effective_endpoint {
validate_public_endpoint(&endpoint).await?;
}
Ok(())
}
#[cfg(feature = "parquet")]
async fn validate_public_endpoint(endpoint: &str) -> error::Result<()> {
let host = extract_host(endpoint).ok_or_else(|| {
error::Error::BadRequest(format!("Invalid object storage endpoint: {endpoint}"))
})?;
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((host.as_str(), 443u16))
.await
.map_err(|e| anyhow::anyhow!("error writing file to {path}: {e:#}"))?;
let content = client
.get(&path)
.await
.map_err(to_anyhow)?
.bytes()
.await
.map_err(to_anyhow)?;
if content != Bytes::from_static(b"hello") {
return Err(error::Error::internal_err(
"Failed to read back from blob storage".to_string(),
));
.map_err(|e| {
error::Error::BadRequest(format!(
"Could not resolve object storage endpoint '{host}': {e}"
))
})?
.collect();
if addrs.is_empty() {
return Err(error::Error::BadRequest(format!(
"Could not resolve object storage endpoint '{host}'"
)));
}
// Reject if any resolved address is non-public, which also defeats the simplest DNS-rebinding
// attempts (a name resolving to both a public and a private address).
for addr in addrs {
if is_forbidden_ip(addr.ip()) {
return Err(error::Error::NotAuthorized(
"Testing object storage at a private, loopback, or link-local endpoint requires a super admin"
.to_string(),
));
}
}
Ok(())
}
// Strip a leading `http://`/`https://` scheme case-insensitively (URL schemes are
// case-insensitive), returning the remainder when one was present.
#[cfg(feature = "parquet")]
fn strip_http_scheme(s: &str) -> Option<&str> {
for scheme in ["https://", "http://"] {
let b = scheme.as_bytes();
if s.len() >= b.len() && s.as_bytes()[..b.len()].eq_ignore_ascii_case(b) {
return Some(&s[b.len()..]);
}
}
None
}
#[cfg(feature = "parquet")]
fn extract_host(endpoint: &str) -> Option<String> {
let mut s = endpoint.trim();
if let Some(rest) = strip_http_scheme(s) {
s = rest;
}
s = s.split(['/', '?', '#', '\\']).next().unwrap_or(s);
if let Some((_, rest)) = s.rsplit_once('@') {
s = rest;
}
let host = if let Some(rest) = s.strip_prefix('[') {
// IPv6 literal, e.g. [::1]:9000
rest.split(']').next().unwrap_or(rest)
} else {
// host or host:port
s.split(':').next().unwrap_or(s)
}
.trim();
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
#[cfg(feature = "parquet")]
fn is_forbidden_ip(ip: std::net::IpAddr) -> bool {
use std::net::{IpAddr, Ipv4Addr};
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local() // 169.254.0.0/16, incl. the cloud metadata endpoint
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_multicast()
|| v4.octets()[0] == 0 // 0.0.0.0/8
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64) // 100.64.0.0/10 CGNAT
}
IpAddr::V6(v6) => {
// Any IPv4 embedded in an IPv6 address (IPv4-mapped ::ffff:0:0/96, IPv4-compatible
// ::/96, or NAT64 64:ff9b::/96) is re-checked against the IPv4 rules, so e.g.
// 64:ff9b::169.254.169.254 cannot route to the metadata endpoint in a NAT64 network.
let seg = v6.segments();
let is_v4_compatible = seg[0..6] == [0, 0, 0, 0, 0, 0];
let is_nat64 = seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0];
if let Some(v4) = v6.to_ipv4_mapped() {
return is_forbidden_ip(IpAddr::V4(v4));
}
if is_v4_compatible || is_nat64 {
let embedded = Ipv4Addr::new(
(seg[6] >> 8) as u8,
(seg[6] & 0xff) as u8,
(seg[7] >> 8) as u8,
(seg[7] & 0xff) as u8,
);
if is_forbidden_ip(IpAddr::V4(embedded)) {
return true;
}
}
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (seg[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local
|| (seg[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
}
}
client.delete(&path).await.map_err(to_anyhow)?;
Ok("Tested blob storage successfully".to_string())
}
#[cfg(feature = "parquet")]
@@ -1861,3 +2109,111 @@ mod tests {
);
}
}
#[cfg(all(test, feature = "parquet"))]
mod object_storage_test_hardening {
use super::{extract_host, is_forbidden_ip, validate_object_storage_test};
use std::net::IpAddr;
use windmill_object_store::ObjectSettings;
// IP literals (not hostnames) keep validate_public_endpoint deterministic — `lookup_host`
// parses them without any network round-trip.
fn gcs_settings(gcs_base_url: &str) -> ObjectSettings {
serde_json::from_value(serde_json::json!({
"type": "Gcs",
"bucket": "b",
"serviceAccountKey": { "gcs_base_url": gcs_base_url, "client_email": "x@y.z" }
}))
.unwrap()
}
#[tokio::test]
async fn rejects_gcs_internal_base_url() {
// gcs_base_url in the service-account key must not smuggle an internal host past the check,
// including via a mixed-case scheme (URL schemes are case-insensitive).
for url in [
"http://169.254.169.254",
"HTTP://169.254.169.254",
"Https://10.0.0.5",
] {
assert!(
validate_object_storage_test(&gcs_settings(url))
.await
.is_err(),
"{url} should be rejected"
);
}
}
#[tokio::test]
async fn allows_gcs_public_base_url() {
assert!(
validate_object_storage_test(&gcs_settings("https://8.8.8.8"))
.await
.is_ok()
);
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn forbids_internal_ips() {
for s in [
"127.0.0.1", // loopback
"169.254.169.254", // cloud metadata (link-local)
"10.0.0.5", // private
"172.16.3.4", // private
"192.168.1.10", // private
"0.0.0.0", // unspecified
"100.64.0.1", // CGNAT
"::1", // IPv6 loopback
"fe80::1", // IPv6 link-local
"fc00::1", // IPv6 unique local
"::ffff:127.0.0.1", // IPv4-mapped loopback
"::ffff:169.254.169.254", // IPv4-mapped metadata
"::169.254.169.254", // IPv4-compatible metadata
"64:ff9b::169.254.169.254", // NAT64-embedded metadata
"64:ff9b::a9fe:a9fe", // NAT64-embedded metadata (hex form)
] {
assert!(is_forbidden_ip(ip(s)), "{s} should be forbidden");
}
}
#[test]
fn allows_public_ips() {
for s in ["8.8.8.8", "1.1.1.1", "52.95.110.1", "2606:4700:4700::1111"] {
assert!(!is_forbidden_ip(ip(s)), "{s} should be allowed");
}
}
#[test]
fn extracts_host_from_endpoint() {
let cases = [
("s3.amazonaws.com", Some("s3.amazonaws.com")),
("https://minio.internal:9000", Some("minio.internal")),
("http://10.0.0.5:9000/bucket", Some("10.0.0.5")),
("user:pass@host.example:443", Some("host.example")),
("[::1]:9000", Some("::1")),
("https://[fe80::1]/x", Some("fe80::1")),
("", None),
// Injection via region/bucket interpolation into the default endpoint string: the
// userinfo `@` and the path `/` must not hide the real authority from the host check.
(
"https://s3.@169.254.169.254/.amazonaws.com",
Some("169.254.169.254"),
),
(
"https://@169.254.169.254/mybucket.s3.amazonaws.com",
Some("169.254.169.254"),
),
("s3.#@169.254.169.254/x.amazonaws.com", Some("s3.")),
// Scheme is case-insensitive.
("HTTP://169.254.169.254", Some("169.254.169.254")),
];
for (input, expected) in cases {
assert_eq!(extract_host(input).as_deref(), expected, "input: {input}");
}
}
}
@@ -339,13 +339,15 @@ async fn cleanup_job_logs(
return Ok(());
}
let mut completed_at_floor: Option<DateTime<Utc>> = None;
loop {
let (deleted_count, rel_paths) =
delete_expired_jobs_batch(db, retention_secs, JOB_BATCH).await?;
let (deleted_count, rel_paths, max_completed_at) =
delete_expired_jobs_batch(db, retention_secs, JOB_BATCH, completed_at_floor).await?;
if deleted_count == 0 {
break;
}
completed_at_floor = max_completed_at.or(completed_at_floor);
let s3_paths: Vec<ObjectPath> = rel_paths
.iter()
@@ -382,7 +384,8 @@ async fn delete_expired_jobs_batch(
db: &DB,
job_retention_secs: i64,
batch_size: i64,
) -> error::Result<(usize, Vec<String>)> {
completed_at_floor: Option<DateTime<Utc>>,
) -> error::Result<(usize, Vec<String>, Option<DateTime<Utc>>)> {
let mut tx = db.begin().await?;
let active_root_job_ids: Vec<Uuid> = sqlx::query_scalar!(
@@ -395,33 +398,61 @@ async fn delete_expired_jobs_batch(
.fetch_all(&mut *tx)
.await?;
// Active-root exclusion via NOT IN (hashed SubPlan) instead of `!= ALL($3)`;
// see backend/src/monitor.rs::delete_expired_jobs_batch for the rationale.
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
)
RETURNING id",
job_retention_secs,
batch_size,
&active_root_job_ids
)
.fetch_all(&mut *tx)
.await?;
// `completed_at_floor` carries a watermark across batches so each one resumes after the rows
// the previous batch processed instead of re-scanning the (potentially undeletable) oldest
// prefix; the empty-active-roots branch skips the v2_job join entirely. See
// backend/src/monitor.rs::delete_expired_jobs_batch for the full rationale.
let (deleted_jobs, max_completed_at) = if active_root_job_ids.is_empty() {
let rows = sqlx::query!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT id FROM v2_job_completed
WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval
AND ($3::timestamptz IS NULL OR completed_at >= $3)
ORDER BY completed_at ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
)
RETURNING id, completed_at",
job_retention_secs,
batch_size,
completed_at_floor,
)
.fetch_all(&mut *tx)
.await?;
let max = rows.iter().map(|r| r.completed_at).max();
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
} else {
let rows = sqlx::query!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
)
RETURNING id, completed_at",
job_retention_secs,
batch_size,
&active_root_job_ids,
completed_at_floor,
)
.fetch_all(&mut *tx)
.await?;
let max = rows.iter().map(|r| r.completed_at).max();
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
};
let deleted_count = deleted_jobs.len();
if deleted_count == 0 {
tx.commit().await?;
return Ok((0, Vec::new()));
return Ok((0, Vec::new(), max_completed_at));
}
if let Err(e) = sqlx::query!(
@@ -471,7 +502,7 @@ async fn delete_expired_jobs_batch(
tx.commit().await?;
Ok((deleted_count, log_paths))
Ok((deleted_count, log_paths, max_completed_at))
}
/// Scan S3 under the `logs/` prefix for orphan log files and delete them.
+15 -1
View File
@@ -27,7 +27,7 @@ use axum::{
Json, Router,
};
use hyper::{header::LOCATION, StatusCode};
use windmill_api_auth::require_super_admin;
use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin, OptJobAuthed};
use windmill_common::usernames::{
generate_instance_wide_unique_username, get_instance_username_or_create_pending,
};
@@ -1415,11 +1415,13 @@ async fn convert_user_to_group(
async fn update_user(
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Path(email_to_update): Path<String>,
Extension(db): Extension<DB>,
Json(eu): Json<EditUser>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
let mut new_super_admin: Option<bool> = None;
@@ -1581,10 +1583,12 @@ async fn update_user(
async fn delete_user(
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Path(email_to_delete): Path<String>,
Extension(db): Extension<DB>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_delete)
@@ -1877,9 +1881,11 @@ async fn set_login_type(
Extension(db): Extension<DB>,
Path(email): Path<String>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(et): Json<EditLoginType>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
sqlx::query!(
@@ -2159,8 +2165,10 @@ pub async fn create_session_token<'c>(
async fn create_token(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(token_config): Json<NewToken>,
) -> Result<(StatusCode, String)> {
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
check_token_create_rate_limit(&authed.username)?;
windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?;
@@ -2176,6 +2184,7 @@ async fn create_token(
async fn impersonate(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(new_token): Json<NewToken>,
) -> Result<(StatusCode, String)> {
use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH;
@@ -2189,6 +2198,7 @@ async fn impersonate(
Some(&token)
};
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
if new_token.impersonate_email.is_none() {
return Err(Error::BadRequest(
@@ -2707,8 +2717,10 @@ struct ExportedGlobalUser {
async fn export_global_users(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
) -> JsonResult<Vec<ExportedGlobalUser>> {
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
let users = sqlx::query_as!(
ExportedGlobalUser,
@@ -2744,9 +2756,11 @@ async fn export_global_users() -> JsonResult<String> {
async fn overwrite_global_users(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(users): Json<Vec<ExportedGlobalUser>>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
sqlx::query!("DELETE FROM password")
.execute(&mut *tx)
@@ -717,16 +717,27 @@ async fn create_deployment_request_comment(
// ---- helpers ------------------------------------------------------------
async fn parent_of_fork(db: &DB, w_id: &str) -> Result<String> {
sqlx::query_scalar!(
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
// Resolve the fork's parent and require it to still exist and be active. A
// parent that is archived (soft-deleted) can no longer be accessed, so a
// diff or deployment request against it targets an unreachable workspace.
let parent = sqlx::query!(
"SELECT p.id AS \"id!\", p.deleted AS \"deleted!\"
FROM workspace f
JOIN workspace p ON p.id = f.parent_workspace_id
WHERE f.id = $1",
w_id,
)
.fetch_optional(db)
.await?
.flatten()
.ok_or_else(|| {
Error::BadRequest(format!(
.await?;
match parent {
None => Err(Error::BadRequest(format!(
"workspace {w_id} is not a fork (no parent_workspace_id)"
))
})
))),
Some(p) if p.deleted => Err(Error::BadRequest(format!(
"parent workspace {} of fork {w_id} is archived",
p.id
))),
Some(p) => Ok(p.id),
}
}
@@ -197,6 +197,7 @@ pub fn global_service() -> Router {
.route("/list_as_superadmin", get(list_workspaces_as_super_admin))
.route("/list", get(list_workspaces))
.route("/users", get(user_workspaces))
.route("/session_workspace_status", post(session_workspace_status))
.route("/create", post(create_workspace))
.route("/create_fork", post(deprecated_create_workspace_fork))
.route("/exists", post(exists_workspace))
@@ -3623,6 +3624,47 @@ async fn user_workspaces(
Ok(Json(WorkspaceList { email, workspaces }))
}
#[derive(Deserialize)]
struct SessionWorkspaceStatusRequest {
workspace_ids: Vec<String>,
}
/// Reconciliation support for client-side AI sessions, which the backend cannot touch
/// directly. The client posts the workspace ids its sessions reference and uses the
/// per-id status to keep sessions in sync with workspace lifecycle: `deleted` (no row /
/// no access → unresolvable) drops the sessions, `archived` (soft-deleted, still a
/// member) archives them, `active` restores ones previously archived-by-workspace.
/// Archived and hard-deleted workspaces are absent from `user_workspaces`, so this is the
/// only way the client learns about a change made while it was away or on another device.
async fn session_workspace_status(
Extension(db): Extension<DB>,
ApiAuthed { email, .. }: ApiAuthed,
Json(req): Json<SessionWorkspaceStatusRequest>,
) -> JsonResult<HashMap<String, String>> {
if req.workspace_ids.len() > 1000 {
return Err(Error::BadRequest(
"Too many workspace ids (max 1000)".to_string(),
));
}
let rows = sqlx::query!(
"SELECT req.id AS \"id!\",
(CASE
WHEN usr.email IS NULL THEN 'deleted'
WHEN workspace.deleted THEN 'archived'
ELSE 'active'
END) AS \"status!\"
FROM unnest($1::text[]) AS req(id)
LEFT JOIN workspace ON workspace.id = req.id
LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2",
&req.workspace_ids[..],
email,
)
.fetch_all(&db)
.await?;
let statuses = rows.into_iter().map(|r| (r.id, r.status)).collect();
Ok(Json(statuses))
}
pub async fn check_w_id_conflict<'c>(tx: &mut Transaction<'c, Postgres>, w_id: &str) -> Result<()> {
if w_id == "global" {
return Err(windmill_common::error::Error::BadRequest(
@@ -65,13 +65,22 @@ pub(crate) async fn change_workspace_id(
old_id, rw.new_id
);
// Create new workspace with new id and name
// Create new workspace with new id and name. A fork that keeps a wm-fork-
// id must carry its parent_workspace_id over, otherwise it becomes a
// parentless "fork of nothing" with no source to compare or merge against.
// A non-fork target id means the workspace is being promoted out of a fork,
// so the parent pointer is intentionally cleared.
info!("Creating new workspace row");
let new_is_fork = rw.new_id.starts_with(WM_FORK_PREFIX);
sqlx::query!(
"INSERT INTO workspace SELECT $1, $2, owner, false, premium FROM workspace WHERE id = $3",
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id)
SELECT $1, $2, owner, false, premium,
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END
FROM workspace WHERE id = $3",
&rw.new_id,
&rw.new_name,
&old_id
&old_id,
new_is_fork
)
.execute(&mut *tx)
.await?;
@@ -347,6 +356,18 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
// Re-parent child forks: any fork whose parent_workspace_id was the old id
// must follow the renamed parent to the new id, otherwise it is left
// pointing at the soft-deleted old shell (whose data has moved here).
info!("Re-parenting child forks to the new workspace id");
sqlx::query!(
"UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2",
&rw.new_id,
&old_id
)
.execute(&mut *tx)
.await?;
info!("Updating workspace_protection_rule table");
sqlx::query!(
"UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2",
+34 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.737.0
version: 1.738.0
title: Windmill API
contact:
@@ -997,6 +997,39 @@ paths:
schema:
$ref: "#/components/schemas/UserWorkspaceList"
/workspaces/session_workspace_status:
post:
summary: get the lifecycle status of workspaces referenced by client-side sessions
operationId: getSessionWorkspaceStatus
tags:
- workspace
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
workspace_ids:
type: array
items:
type: string
required:
- workspace_ids
responses:
"200":
description: map of workspace id to status (active, archived, or deleted)
content:
application/json:
schema:
type: object
additionalProperties:
type: string
enum:
- active
- archived
- deleted
/w/{workspace}/workspaces/get_as_superadmin:
get:
summary: get workspace as super admin (require to be super admin)
+6
View File
@@ -84,6 +84,12 @@ lazy_static::lazy_static! {
(20260228000000, include_str!(
"../../migrations/20260228000000_v2_job_completed_failure_index.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
(20260610151334, include_str!(
"../../migrations/20260610151334_folder_labels.up.sql"
).replace("SET search_path = public", "SET search_path FROM CURRENT").to_string()),
(20260614075900, include_str!(
"../../migrations/20260614075900_dedup_folder_labels.up.sql"
).replace("SET search_path = public", "SET search_path FROM CURRENT").to_string()),
].into_iter().collect();
}
+4 -2
View File
@@ -1,13 +1,13 @@
use std::collections::HashMap;
use crate::db::ApiAuthed;
use crate::db::{ApiAuthed, OptJobAuthed};
use crate::secret_backend_ext::rename_vault_secrets_with_prefix;
use axum::{
extract::{Extension, Path},
Json,
};
use serde::{Deserialize, Serialize};
use windmill_api_auth::require_super_admin;
use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin};
use windmill_api_users::users::delete_workspace_user_internal;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
@@ -483,11 +483,13 @@ pub(crate) async fn global_offboard_preview(
pub(crate) async fn offboard_global_user(
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Extension(db): Extension<DB>,
Path(email): Path<String>,
Json(req): Json<GlobalOffboardRequest>,
) -> Result<Json<OffboardResponse>> {
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let workspaces = sqlx::query!(
"SELECT workspace_id, username FROM usr WHERE email = $1",
+10 -2
View File
@@ -11,7 +11,7 @@ pub use windmill_api_users::users::*;
use std::sync::Arc;
use crate::db::ApiAuthed;
use crate::db::{ApiAuthed, OptJobAuthed};
use crate::secret_backend_ext::rename_vault_secrets_with_prefix;
use argon2::Argon2;
use axum::{
@@ -21,7 +21,7 @@ use axum::{
};
use hyper::StatusCode;
use serde::Deserialize;
use windmill_api_auth::require_super_admin;
use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::audit::AuditAuthor;
@@ -71,11 +71,13 @@ pub fn make_unauthed_service() -> Router {
async fn create_user(
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Extension(db): Extension<DB>,
Extension(webhook): Extension<windmill_common::webhook::WebhookShared>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
Json(nu): Json<NewUser>,
) -> Result<(StatusCode, String)> {
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
crate::users_oss::create_user(authed, db, webhook, argon2, nu).await
}
@@ -141,8 +143,10 @@ async fn set_password(
Extension(db): Extension<DB>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(ep): Json<EditPassword>,
) -> Result<String> {
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let email = authed.email.clone();
crate::users_oss::set_password(db, argon2, authed, &email, ep).await
}
@@ -152,9 +156,11 @@ async fn set_password_of_user(
Extension(argon2): Extension<Arc<Argon2<'_>>>,
Path(email): Path<String>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(ep): Json<EditPassword>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
crate::users_oss::set_password(db, argon2, authed, &email, ep).await
}
@@ -165,11 +171,13 @@ struct RenameUser {
async fn rename_user(
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Path(user_email): Path<String>,
Extension(db): Extension<DB>,
Json(ru): Json<RenameUser>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
+97 -2
View File
@@ -693,8 +693,6 @@ pub fn is_allowed_file_location(job_dir: &str, user_defined_path: &str) -> error
let full_path = job_dir.join(&user_path);
// let normalized_job_dir = std::fs::canonicalize(job_dir)?;
// let normalized_full_path = std::fs::canonicalize(&full_path)?;
let normalized_job_dir = normalize_path(job_dir);
let normalized_full_path = normalize_path(&full_path);
@@ -706,6 +704,36 @@ pub fn is_allowed_file_location(job_dir: &str, user_defined_path: &str) -> error
.into());
}
// The lexical check above cannot see symlinks: a symlink planted inside the
// job dir - e.g. by an earlier Ansible `git_repos` clone whose tracked
// content includes one - would let a later `git clone` or file write follow
// it out of the job dir while still passing the textual `starts_with` check.
// Walk the *normalized* relative path (`..`/`.` already collapsed) so each
// step matches the real on-disk resolution, and reject any existing component
// that is a symlink. Walking the raw user path would drift on an in-bounds
// `..` (e.g. `foo/../link`, which normalizes back inside the job dir) and miss
// the real symlinked component. Not-yet-existing components are safe: a path
// that does not exist cannot itself be a symlink.
let relative = normalized_full_path
.strip_prefix(&normalized_job_dir)
.unwrap_or(&normalized_full_path);
let mut current = normalized_job_dir.clone();
for component in relative.components() {
if let Component::Normal(c) = component {
current.push(c);
if std::fs::symlink_metadata(&current)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Path traverses a symlink, which is not allowed.",
)
.into());
}
}
}
Ok(normalized_full_path)
}
@@ -2828,4 +2856,71 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn test_is_allowed_file_location_allows_plain_relative() {
let base = std::env::temp_dir().join(format!("wm_allowed_loc_ok_{}", uuid::Uuid::new_v4()));
let job_dir = base.join("job");
std::fs::create_dir_all(&job_dir).unwrap();
let job_dir_str = job_dir.to_str().unwrap();
let out = is_allowed_file_location(job_dir_str, "repo/sub/playbook.yml").unwrap();
assert_eq!(out, normalize_path(&job_dir.join("repo/sub/playbook.yml")));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn test_is_allowed_file_location_rejects_parent_and_absolute() {
let base =
std::env::temp_dir().join(format!("wm_allowed_loc_esc_{}", uuid::Uuid::new_v4()));
let job_dir = base.join("job");
std::fs::create_dir_all(&job_dir).unwrap();
let job_dir_str = job_dir.to_str().unwrap();
assert!(is_allowed_file_location(job_dir_str, "../escape").is_err());
assert!(is_allowed_file_location(job_dir_str, "a/../../escape").is_err());
assert!(is_allowed_file_location(job_dir_str, "/etc/passwd").is_err());
let _ = std::fs::remove_dir_all(&base);
}
// Regression for GHSA-v934-cvpf-6fjw: a symlink planted inside the job dir
// (e.g. by an earlier `git_repos` clone) must not let a later target traverse
// it out of the job dir, even though the lexical path stays "inside".
#[cfg(unix)]
#[test]
fn test_is_allowed_file_location_rejects_symlink_traversal() {
let base =
std::env::temp_dir().join(format!("wm_allowed_loc_symlink_{}", uuid::Uuid::new_v4()));
let job_dir = base.join("job");
std::fs::create_dir_all(&job_dir).unwrap();
// Stand-in for the shared cache dir living outside the job dir.
let outside = base.join("outside");
std::fs::create_dir_all(&outside).unwrap();
let job_dir_str = job_dir.to_str().unwrap();
// Plant `job/repo` -> `../outside`, as a malicious first clone would.
let planted = job_dir.join("repo");
std::os::unix::fs::symlink(&outside, &planted).unwrap();
// Both the symlink itself and any path traversing it are rejected.
assert!(is_allowed_file_location(job_dir_str, "repo").is_err());
assert!(is_allowed_file_location(job_dir_str, "repo/payload").is_err());
assert!(is_allowed_file_location(job_dir_str, "repo/sub/payload").is_err());
// An in-bounds `..` must not bypass the check: `foo/../repo/payload`
// normalizes back to `repo/payload` and still traverses the symlink.
assert!(is_allowed_file_location(job_dir_str, "foo/../repo/payload").is_err());
std::fs::create_dir(job_dir.join("real")).unwrap();
assert!(is_allowed_file_location(job_dir_str, "real/../repo/payload").is_err());
// A dangling symlink (target does not exist yet) is still caught:
// `symlink_metadata` does not follow the link.
let dangling = job_dir.join("dangling");
std::os::unix::fs::symlink(base.join("nonexistent"), &dangling).unwrap();
assert!(is_allowed_file_location(job_dir_str, "dangling/payload").is_err());
let _ = std::fs::remove_dir_all(&base);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.737.0";
export const VERSION = "v1.738.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.737.0";
export const VERSION = "1.738.0";
+1 -1
View File
@@ -54,7 +54,7 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo
ENV TZ=Etc/UTC
# Install UV
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
# Preinstall python runtime to temp location (will copy with world-writable perms later)
# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run
+1 -1
View File
@@ -54,7 +54,7 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo
ENV TZ=Etc/UTC
# Install UV
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
# Preinstall python runtime to temp location (will copy with world-writable perms later)
# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1764517877,
"narHash": "sha256-pp3uT4hHijIC8JUK5MEqeAWmParJrgBVzHLNfJDZxg4=",
"lastModified": 1768377964,
"narHash": "sha256-RU35vQnfg9NwJUviGCfMH9ChgHANoNSiRaAn4/wINT4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2d293cbfa5a793b4c50d17c05ef9e385b90edf6c",
"rev": "cadda13afe838615fb74b0a9720905920559c535",
"type": "github"
},
"original": {
+689 -30
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.737.0",
"version": "1.738.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
@@ -127,6 +127,7 @@
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"mdast-util-find-and-replace": "^3.0.2",
"mermaid": "^11.15.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
"monaco-languageclient": "10.6.0",
+19 -5
View File
@@ -30,6 +30,10 @@
placement?: Placement
usePointerDownOutside?: boolean
closeOnOtherDropdownOpen?: boolean
// When false the menu stays open after an item is selected (melt's closeOnItemClick).
// Consumers that keep the menu open must close it themselves where appropriate.
// Read once at menu creation (like `placement`); changing it after mount has no effect.
closeOnItemClick?: boolean
fixedHeight?: boolean
hidePopup?: boolean
open?: boolean
@@ -41,10 +45,18 @@
size?: ButtonType.UnifiedSize
btnText?: string
buttonReplacement?: import('svelte').Snippet
// In customMenu mode the snippet receives the melt-ui `item` action
// store so consumers can wrap their own rows in <MenuItem> (or
// `use:melt={$item}`) and get arrow-key navigation + aria wiring.
menu?: import('svelte').Snippet<[{ item: MenubarMenuElements['item']; close: () => void }]>
// In customMenu mode the snippet receives the melt-ui `item` action store
// (so consumers can wrap rows in <MenuItem> for arrow-key navigation + aria)
// and `builders` (so they can compose melt submenus, e.g. via DropdownSubmenuItem).
menu?: import('svelte').Snippet<
[
{
item: MenubarMenuElements['item']
close: () => void
builders: ReturnType<typeof createDropdownMenu>['builders']
}
]
>
maxHeight?: string | undefined
}
@@ -56,6 +68,7 @@
placement = 'bottom-end',
usePointerDownOutside = false,
closeOnOtherDropdownOpen = true,
closeOnItemClick = true,
fixedHeight = true,
hidePopup = false,
open = $bindable(false),
@@ -82,6 +95,7 @@
positioning: {
placement: untrack(() => placement)
},
closeOnItemClick: untrack(() => closeOnItemClick),
loop: true,
onOpenChange: ({ next }) => {
if (closeOnOtherDropdownOpen) {
@@ -176,7 +190,7 @@
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
>
{#if customMenu}
{@render menu?.({ item, close })}
{@render menu?.({ item, close, builders })}
{:else}
<div
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
+53 -60
View File
@@ -158,11 +158,6 @@
preparedAssetsSqlQueries?: InferAssetsSqlQueryDetails[] | undefined
// To execute preview scripts with the right worker group
customTag?: string
// Opt-in: reflect external `code` prop mutations back into Monaco (see
// the effect below). One-way `code={...}` callers that need live
// external updates — e.g. the inline flow rawscript — set this. Off by
// default so every other caller's behavior is unchanged.
syncExternalCode?: boolean
}
let {
@@ -195,8 +190,7 @@
enablePreprocessorSnippet = false,
rawAppRunnableKey = undefined,
preparedAssetsSqlQueries,
customTag,
syncExternalCode = false
customTag
}: Props = $props()
$effect.pre(() => {
@@ -375,23 +369,12 @@
code = ncode
}
if (noHistory) {
editor?.setValue(ncode)
} else {
if (editor?.getModel()) {
// editor.setValue(ncode)
editor.pushUndoStop()
editor.executeEdits('set', [
{
range: editor.getModel()!.getFullModelRange(), // full range
text: ncode
}
])
editor.pushUndoStop()
}
}
// setCode is an authoritative overwrite (reset, AI apply, module switch).
// Cancel any in-flight keystroke debounce first: otherwise alignCodeWithEditor
// skips on the `timeoutModel` guard (leaving Monaco stale), and the pending
// updateCode later reads the old buffer and writes it back over `ncode`.
cancelPendingChanges()
alignCodeWithEditor(!noHistory)
// Dispatch change immediately when code actually changed. This ensures
// callers like the Reset button and copilot trigger on:change handlers.
// The debounced onDidChangeModelContent handler will no-op since code
@@ -425,6 +408,7 @@
return
}
code = ncode
lastEditorCode = ncode
dispatch('change', ncode)
}
@@ -436,12 +420,19 @@
* see it. Clears the chain state so the next keystroke after this
* flush is a fresh leading fire. */
export function flushPendingChanges(): void {
cancelPendingChanges()
updateCode()
}
/** Discard any in-flight keystroke debounce without materializing it, so a
* deferred updateCode can't fire later. Resets chain state to a fresh leading
* fire on the next keystroke. */
function cancelPendingChanges(): void {
if (timeoutModel !== undefined) {
clearTimeout(timeoutModel)
timeoutModel = undefined
}
changeChainStart = undefined
updateCode()
}
export function append(code: string): void {
@@ -1901,29 +1892,6 @@
lang = scriptLangToEditorLang(scriptLang)
})
// Opt-in (syncExternalCode): reflect external `code` prop mutations into
// Monaco's model. Parents that pass `code={...}` one-way (no bind) — e.g.
// the inline rawscript in the flow editor — otherwise mutate the prop
// without Monaco ever showing the change (the AI chat editing a flow
// module's content in a session is the motivating case). Gated off by
// default: Editor is sensitive and most callers either bind:code (and
// carry their own external-sync) or treat code as init-only, so a blanket
// setValue would risk clobbering them. The `getValue() !== code` guard
// keeps the caret intact when the change originated from typing inside
// Monaco (which round-trips code back via `$bindable`, re-firing this
// effect with `code === getValue()`).
let lastExternalCodeSync = code
$effect(() => {
if (!syncExternalCode) return
if (code === lastExternalCodeSync) return
lastExternalCodeSync = code
if (!editor) return
untrack(() => {
if (editor!.getValue() !== code) {
editor!.setValue(code ?? '')
}
})
})
$effect(() => {
filePath = computePath(path)
})
@@ -2011,25 +1979,50 @@
})
})
// External `code` prop changes should flow into the Monaco editor. The
// `untrack` block reads/writes Monaco without subscribing — only the
// prop read above is tracked — so the editor's own change handler
// (`updateCode`) re-running with the same value short-circuits and we
// don't loop.
$effect(() => {
const next = code ?? ''
let applyExternalCode = useDebounce(() => alignCodeWithEditor(true), 800)
// Last `code` value the editor itself produced or aligned to. Used to tell an
// echo (the bindable changed because the user typed — Monaco is already
// ahead) from a genuine external write. Without this, a typing burst longer
// than the debounce window would sync the lagging `code` back over newer
// keystrokes. Must be kept in step with every editor↔`code` sync point.
let lastEditorCode = code
function alignCodeWithEditor(history: boolean) {
const ed = editor
if (!ed) return
untrack(() => {
if (ed.getValue() === next) return
const model = ed.getModel()
if (!model) return
const next = code ?? ''
const value = ed.getValue()
const model = ed.getModel()
// Some keystrokes are still being debounced, don't overwrite them.
// When the debounce is done, updateCode will be called and the code will be aligned with the editor.
if (timeoutModel !== undefined) return
if (!model) return
lastEditorCode = next
if (value === next) return
if (history) {
ed.pushUndoStop()
ed.executeEdits('external', [{ range: model.getFullModelRange(), text: next }])
ed.pushUndoStop()
} else {
ed.setValue(next)
}
}
// External `code` prop changes should flow into the Monaco editor. Skip
// echoes: when `code` matches what the editor last produced (`updateCode`)
// or aligned to, the change came from the editor itself, so syncing back
// would clobber input typed since. Only genuine external writes — where
// `code` diverges from `lastEditorCode` — schedule a sync. The `untrack`
// block reads/writes Monaco without subscribing, so we don't loop.
$effect(() => {
;[code, editor]
if (!editor) return
untrack(() => {
if (code === lastEditorCode) return
applyExternalCode()
})
})
let isTsWorkerInitialized = resource([() => lang, () => initialized], async () => {
if (lang !== 'typescript' || !initialized) return false
// Use the stable model URI (computed once at mount), not filePath which changes on rename
@@ -1180,15 +1180,25 @@
export type FlowModuleForTimeline = {
id: string
type: FlowModuleValue['type']
suspend?: boolean
}
function allModulesForTimeline(
modules: FlowModule[],
expandedSubflows: Record<string, { modules: FlowModule[]; groups?: any[] }>
): FlowModuleForTimeline[] {
const ids = dfs(modules, (x) => ({ id: x.id, type: x.value.type }) as FlowModuleForTimeline, {
skipToolNodes: true
})
const ids = dfs(
modules,
(x) =>
({
id: x.id,
type: x.value.type,
suspend: x.suspend != undefined
}) as FlowModuleForTimeline,
{
skipToolNodes: true
}
)
function rec(
ids: FlowModuleForTimeline[],
@@ -1208,7 +1218,8 @@
fms,
(x) => ({
id: x.id.startsWith('subflow:') ? x.id : buildSubflowKey(x.id, nprefix),
type: x.value.type
type: x.value.type,
suspend: x.suspend != undefined
}),
{ skipToolNodes: true }
),
@@ -72,6 +72,66 @@
}
const barHeight = 32
// Whole approval-wait machinery below is inert unless a step actually has a suspend config,
// so large suspend-free flows pay nothing for the flatten/sort and the per-tick recompute.
const hasSuspendModule = $derived(flowModules.some((m) => m.suspend))
// Push times of every job on the timeline, ascending. Used to locate when the step
// that follows an approval step started — i.e. the moment the approval was granted.
const allCreatedAts = $derived(
hasSuspendModule
? Object.values(items ?? {})
.flat()
.map((j) => j.created_at)
.filter((t): t is number => t != undefined)
.sort((a, b) => a - b)
: []
)
// Heuristic: the grant moment is approximated by the next job pushed anywhere on the
// timeline. Exact for sequential flows; for an approval step inside one branch of a
// parallel branchall a concurrent sibling job can land first and understate the wait.
function nextCreatedAtAfter(t: number): number | undefined {
return allCreatedAts.find((c) => c > t)
}
// For a completed suspend/approval step, the time spent waiting for the approval is the
// gap between the step finishing and the next step being pushed (or now, if still waiting).
function approvalWait(b: {
started_at?: number
duration_ms?: number
}): { start: number; len: number; running: boolean } | undefined {
if (b.started_at == undefined || b.duration_ms == undefined) {
return undefined
}
const end = b.started_at + b.duration_ms
const next = nextCreatedAtAfter(end)
const waitEnd = next ?? (flowDone ? undefined : now)
if (waitEnd == undefined) {
return undefined
}
const len = waitEnd - end
if (len < 100) {
return undefined
}
return { start: end, len, running: next == undefined }
}
// Approval wait per module id, computed once and consumed by both the rows and the legend.
const approvalWaitByModule = $derived.by(() => {
const result: Record<string, { start: number; len: number; running: boolean }> = {}
for (const m of flowModules) {
if (!m.suspend) continue
const sub = (items?.[m.id] ?? []).filter((x) => x.created_at && x.started_at)
if (sub.length !== 1) continue
const aw = approvalWait(sub[0])
if (aw) result[m.id] = aw
}
return result
})
const hasApprovalWait = $derived(Object.keys(approvalWaitByModule).length > 0)
</script>
<OnChange
@@ -95,6 +155,12 @@
<div class="h-2.5 w-2.5 rounded-sm bg-blue-500/90"></div>
<span>Execution</span>
</div>
{#if hasApprovalWait}
<div class="flex gap-1.5 items-center">
<div class="h-2.5 w-2.5 rounded-sm bg-purple-400/80"></div>
<span>Approval wait</span>
</div>
{/if}
{#if max && min}
<span class="font-mono">{msToSec(max - min, 1)}s</span>
{/if}
@@ -113,7 +179,7 @@
/>
</div>
{/if}
{#each flowModules as { id: k, type: typ } (k)}
{#each flowModules as { id: k, type: typ, suspend: isSuspend } (k)}
{@const subItems = items?.[k]?.filter((x) => x.created_at && x.started_at)}
<div class="relative px-3 py-1.5">
<div class="flex items-center justify-between mb-0.5">
@@ -161,6 +227,7 @@
? 0
: now - b?.created_at
: 0}
{@const aw = isSuspend ? approvalWaitByModule[k] : undefined}
<div class="flex w-full py-0.5 items-center" {style}>
<TimelineBar
position="left"
@@ -175,7 +242,7 @@
/>
{#if b.started_at}
<TimelineBar
position={waitingLen < 100 ? 'center' : 'right'}
position={aw || waitingLen < 100 ? 'center' : 'right'}
id={b?.id}
{total}
{min}
@@ -185,6 +252,20 @@
running={b?.duration_ms == undefined}
/>
{/if}
{#if aw}
<TimelineBar
position="right"
id={b?.id}
{total}
{min}
concat
colorClass="bg-purple-400/80"
tooltip={`Waiting for approval — ${msToSec(aw.len, 1)}s`}
started_at={aw.start}
len={aw.len}
running={aw.running}
/>
{/if}
</div>
{:else}
<div class="flex w-full py-0.5"></div>
@@ -196,7 +277,6 @@
</div>
{/each}
</div>
{:else}
<Loader2 class="animate-spin" />
{/if}
@@ -12,10 +12,14 @@
let comparison: WorkspaceComparison | undefined = $state(undefined)
let error: string | undefined = $state(undefined)
let isFork = $derived($workspaceStore?.startsWith('wm-fork-') ?? false)
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
// A fork must have a parent to compare/merge against. Treating the wm-fork-
// prefix alone as "is a fork" renders a parentless "Fork of ()" banner when
// the parent linkage was dropped (e.g. by a workspace id change), so require
// both, matching the forks/compare page.
let isFork = $derived(($workspaceStore?.startsWith('wm-fork-') ?? false) && !!parentWorkspaceId)
// Drafts in this fork. When the fork is otherwise in sync with its parent, a
// user with only pending drafts should still get the draft CTA (mirrors the
@@ -1063,6 +1063,14 @@
<li
>AI chat usage (provider, model, mode, session count, message count — last 30 days)</li
>
<li
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
apps, raw apps)</li
>
<li
>infrastructure info (container runtime, managed database provider, database version,
size and cluster size, max and active connections, object storage backend)</li
>
</ul>
<br />For air-gapped instances, you can download the telemetry data and send it manually.
</div>
@@ -1101,6 +1109,10 @@
<li
>AI chat usage (provider, model, mode, session count, message count — last 30 days)</li
>
<li
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
apps, raw apps)</li
>
</ul>
</div>
{/if}
@@ -952,9 +952,7 @@
}
})
})
$effect(() => {
readFieldsRecursively(script)
})
// Mirror the draft triggers (held in a separate `triggersState` $state)
// back into `script.draft_triggers` so the UserDraft autosave — which
// deep-tracks `script` — picks them up. Pre-PR ScriptBuilder ran its own
@@ -268,6 +268,7 @@
if (activeModuleTab === null && code !== lastSyncedCode) {
editorCode = code
lastSyncedCode = code
editor?.setCode(editorCode) // immediate sync, don't wait for the 800ms debounce
untrack(() => inferSchema(code))
}
})
@@ -1591,18 +1592,29 @@
let error = $derived(getError(testJob))
$effect(() => {
const options: ScriptOptions = {
code,
lang: lang as ScriptLang,
error,
args: args ?? {},
path,
;[
editor,
lastSavedCode,
lastDeployedCode,
diffMode,
workflowAsCode: workflowAsCodeAiContext
}
workflowAsCodeAiContext,
args,
error,
lang,
path
]
untrack(() => {
const options: ScriptOptions = {
getCode: () => code,
lang: lang as ScriptLang,
error,
args: args ?? {},
path,
lastSavedCode,
lastDeployedCode,
diffMode,
workflowAsCode: workflowAsCodeAiContext
}
aiChatManager.scriptEditorOptions = options
aiChatManager.scriptEditorApplyCode = async (code: string, opts?: ReviewChangesOpts) => {
hideDiffMode()
@@ -10,7 +10,7 @@
import { base } from '$lib/base'
import SearchItems from './SearchItems.svelte'
import { page } from '$app/state'
import { goto as gotoUrl } from '$app/navigation'
import { replaceState } from '$app/navigation'
import Version from './Version.svelte'
import Uptodate from './Uptodate.svelte'
import InstanceSettings from './InstanceSettings.svelte'
@@ -70,7 +70,16 @@
const index = page.url.href.lastIndexOf('#')
if (index === -1) return
const hashRemoved = page.url.href.slice(0, index)
gotoUrl(hashRemoved)
// Strip the drawer's URL hash without a SvelteKit navigation: a `goto`
// here re-fires path-reactive effects on the underlying page (e.g. the
// script editor's load effect), wiping unsaved editor content.
try {
replaceState(hashRemoved, page.state)
} catch (e) {
// replaceState throws if the router isn't initialized yet — possible
// when onDestroy runs during router teardown.
console.error(e)
}
}
onDestroy(() => {
+21 -9
View File
@@ -15,6 +15,10 @@
concat?: boolean
gray?: boolean
spacerClass?: string
/** Overrides the default gray/blue bar color (e.g. to mark an approval wait). */
colorClass?: string
/** Tooltip label shown on hover instead of the default job link. */
tooltip?: string
}
let {
@@ -27,7 +31,9 @@
running,
concat = false,
gray = false,
spacerClass = ''
spacerClass = '',
colorClass = undefined,
tooltip = undefined
}: Props = $props()
</script>
@@ -37,20 +43,26 @@
{/if}
<Popover
style="width: {(len / total) * 100}%"
class="h-5 relative {gray
? 'bg-gray-300 dark:bg-gray-600'
: running
? 'bg-blue-400/90'
: 'bg-blue-500/90'} {position == 'left'
class="h-5 relative {colorClass
? colorClass
: gray
? 'bg-gray-300 dark:bg-gray-600'
: running
? 'bg-blue-400/90'
: 'bg-blue-500/90'} {position == 'left'
? 'rounded-l-md'
: position == 'right'
? 'rounded-r-md'
: 'rounded-md'} center-center text-white text-2xs whitespace-nowrap hover:outline outline-1 outline-black"
>
{#snippet text()}
<a href="{base}/run/{id}" class="inline-flex items-center gap-1" target="_blank"
>{id} <ExternalLink size={14} /></a
>
{#if tooltip}
<span>{tooltip}</span>
{:else}
<a href="{base}/run/{id}" class="inline-flex items-center gap-1" target="_blank"
>{id} <ExternalLink size={14} /></a
>
{/if}
{/snippet}
{#if len > 0}
{@const narrow = len / total < 0.09}
@@ -4,6 +4,9 @@ Inline diff renderer for a single workspace item. Mirrors the per-kind
rendering that DiffDrawer does in its body (`DiffDrawer.svelte:181-271`):
- `flow` → `<FlowDiffViewer>` (its own Graph / YAML toggle inside)
- `raw_app_file` → `<RawAppFileDiff>` (one synthesized raw-app file item: a
single diff with a per-file size guard; the metadata item adds a full-app
YAML expand). Raw apps are exploded into these items by `rawAppDiffToItems`.
- has `content` (scripts) → Tabs(Content | Metadata) with two Monaco diffs
- everything else (apps, resources, variables, schedules, triggers…) →
a single Monaco YAML diff over the metadata
@@ -18,12 +21,15 @@ doesn't reflow the parent.
import Tabs from './common/tabs/Tabs.svelte'
import Tab from './common/tabs/Tab.svelte'
import FlowDiffViewer from './FlowDiffViewer.svelte'
import RawAppFileDiff from './raw_apps/RawAppFileDiff.svelte'
import type { RawAppFileItem } from './raw_apps/rawAppDiffUtils'
import { Loader2 } from 'lucide-svelte'
import { cleanValueProperties, orderedYamlStringify, replaceFalseWithUndefined } from '$lib/utils'
import { scriptLangToEditorLang } from '$lib/scripts'
interface Props {
/** Any WorkspaceItemDiff['kind'] — used only to special-case `flow`. */
/** Any WorkspaceItemDiff['kind'], plus the synthetic `raw_app_file`.
* `flow` and `raw_app_file` are special-cased. */
kind: string
/** Raw value from `getItemValue(kind, path, parentWorkspace)`. Undefined
* for "added" items (don't exist in the parent). */
@@ -33,9 +39,11 @@ doesn't reflow the parent.
currentRaw?: unknown
/** Force unified diff (Monaco renderSideBySide=false). Default false. */
inlineDiff?: boolean
/** For `raw_app_file`: the synthesized per-file diff item to render. */
rawFile?: RawAppFileItem
}
let { kind, originalRaw, currentRaw, inlineDiff = false }: Props = $props()
let { kind, originalRaw, currentRaw, inlineDiff = false, rawFile }: Props = $props()
type Prepared = { lang?: string; content?: string; metadata: string }
@@ -102,6 +110,16 @@ doesn't reflow the parent.
{inlineDiff}
/>
</div>
{:else if kind === 'raw_app_file' && rawFile}
<RawAppFileDiff
original={rawFile.original}
current={rawFile.current}
lang={rawFile.lang}
isMetadata={rawFile.isMetadata}
fullYamlOriginal={rawFile.fullYamlOriginal}
fullYamlCurrent={rawFile.fullYamlCurrent}
{inlineDiff}
/>
{:else if hasContent}
<div class="flex flex-col">
<Tabs bind:selected={contentTab}>
@@ -3,7 +3,9 @@
Visual row for a workspace item (script / flow / app / resource /
schedule / trigger / …). Matches the leaf-row layout used by
WorkspaceItemDrillPicker: RowIcon + summary line on top with mono path
beneath, or just the mono path when there's no summary.
beneath, or just the mono path when there's no summary. With `singleLine`,
both collapse to one row showing `summary ?? secondary` (summary in normal
text, the mono path as the fallback) — denser, for the diff tree.
Pure presentation — the caller controls highlighting / current state via
props, supplies the onclick/onmouseenter handlers, and can pass an
@@ -26,6 +28,9 @@ doesn't steal focus from a sibling search input (matches the picker).
/** For `kind: 'trigger'`, specifies the concrete trigger subtype.
* Forwarded to RowIcon. */
triggerKind?: string
/** For `kind: 'raw_app_file'`, the file name/path — forwarded to RowIcon
* to pick an extension-specific icon. */
iconPath?: string
/** Optional summary text shown above the path. */
summary?: string
/** Mono path (or any secondary identifier). When summary is empty
@@ -47,6 +52,9 @@ doesn't steal focus from a sibling search input (matches the picker).
/** Reserve two lines of height and vertically center the content so
* summary and summary-less rows are the same height (diff viewer). */
uniformHeight?: boolean
/** Collapse to a single line showing `summary ?? secondary` (summary in
* normal text, secondary in mono) instead of stacking both. */
singleLine?: boolean
/** Extra left padding (px) for tree-view indentation. Adds to the
* default `px-3` horizontal padding. */
indent?: number
@@ -69,6 +77,7 @@ doesn't steal focus from a sibling search input (matches the picker).
let {
kind,
triggerKind,
iconPath,
summary,
secondary,
highlighted = false,
@@ -82,7 +91,8 @@ doesn't steal focus from a sibling search input (matches the picker).
onclick,
onmouseenter,
extras,
uniformHeight = false
uniformHeight = false,
singleLine = false
}: Props = $props()
const rootClass = $derived(
@@ -96,6 +106,37 @@ doesn't steal focus from a sibling search input (matches the picker).
)
</script>
{#snippet body()}
<RowIcon {kind} {triggerKind} path={iconPath} size={12} />
<div class={contentClass}>
{#if singleLine}
<div
class="text-xs font-normal text-primary truncate {summary ? '' : 'font-mono'}"
title={summary ?? title ?? secondary}
>
{summary ?? secondary}
</div>
{:else if summary}
<div class="text-xs text-primary truncate" title={summary}>{summary}</div>
<div
class="text-2xs text-secondary font-normal font-mono truncate"
title={title ?? secondary}
>
{secondary}
</div>
{:else}
<div class="text-xs text-primary font-mono truncate" title={title ?? secondary}>
{secondary}
</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
{/snippet}
{#if href}
<a
{href}
@@ -111,27 +152,7 @@ doesn't steal focus from a sibling search input (matches the picker).
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class={contentClass}>
{#if summary}
<div class="text-xs text-primary truncate" title={summary}>{summary}</div>
<div
class="text-2xs text-secondary font-normal font-mono truncate"
title={title ?? secondary}
>
{secondary}
</div>
{:else}
<div class="text-xs text-primary font-mono truncate" title={title ?? secondary}>
{secondary}
</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
{@render body()}
</a>
{:else}
<button
@@ -147,26 +168,6 @@ doesn't steal focus from a sibling search input (matches the picker).
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class={contentClass}>
{#if summary}
<div class="text-xs text-primary truncate" title={summary}>{summary}</div>
<div
class="text-2xs text-secondary font-normal font-mono truncate"
title={title ?? secondary}
>
{secondary}
</div>
{:else}
<div class="text-xs text-primary font-mono truncate" title={title ?? secondary}>
{secondary}
</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
{@render body()}
</button>
{/if}
@@ -25,6 +25,38 @@ describe('parsePipelineAnnotations: tag', () => {
const out = parsePipelineAnnotations('// tagged heavy')
expect(out.tag).toBeUndefined()
})
it('skips a tag value containing whitespace (regular comment false-positive)', () => {
const out = parsePipelineAnnotations('# tag this function so we remember to refactor it later')
expect(out.tag).toBeUndefined()
})
it('skips a tag value longer than 50 chars', () => {
const out = parsePipelineAnnotations('// tag ' + 'x'.repeat(51))
expect(out.tag).toBeUndefined()
})
})
describe('parsePipelineAnnotations: header scan', () => {
it('ignores annotations in the body once code has started', () => {
const code = [
'import pandas as pd',
'',
'def main():',
' # tag each row with its source so downstream steps can filter',
' # on s3://should/not/parse',
' return pd.DataFrame()'
].join('\n')
const out = parsePipelineAnnotations(code)
expect(out.tag).toBeUndefined()
expect(out.triggerAssets).toHaveLength(0)
})
it('tolerates blank lines before code but stops at the first code line', () => {
const code = ['#!/usr/bin/env python', '', '# tag heavy', 'import os', '# tag light'].join('\n')
const out = parsePipelineAnnotations(code)
expect(out.tag).toBe('heavy')
})
})
describe('parsePipelineAnnotations: retry', () => {
@@ -291,8 +291,13 @@ export function parsePipelineAnnotations(code: string): PipelineAnnotations {
}
for (const rawLine of code.split('\n')) {
// Annotations live in the leading comment header: skip blank lines but
// stop at the first line of actual code, so comments inside the body
// (e.g. a regular `# tag ...` prose comment) can't false-positive.
// Mirrors the Rust parse_pipeline_annotations header scan.
if (rawLine.trim() === '') continue
const rest = stripCommentPrefix(rawLine)
if (rest === undefined) continue
if (rest === undefined) break
const inner = rest.trimStart()
const afterPipeline = consumeKeyword(inner, 'pipeline')
@@ -323,7 +328,10 @@ export function parsePipelineAnnotations(code: string): PipelineAnnotations {
const afterTag = consumeKeyword(inner, 'tag')
if (afterTag !== undefined) {
const name = afterTag.trim()
if (name && !out.tag) {
// Worker tags are single-word identifiers; a value with whitespace
// or beyond the script.tag column width is almost certainly a
// regular comment starting with "# tag ...".
if (name && !out.tag && !/\s/.test(name) && name.length <= 50) {
out.tag = name
}
continue
@@ -19,6 +19,7 @@
Unplug,
Workflow
} from 'lucide-svelte'
import FileIcon from '$lib/components/raw_apps/FileIcon.svelte'
interface Props {
kind:
@@ -26,6 +27,7 @@
| 'flow'
| 'app'
| 'raw_app'
| 'raw_app_file'
| 'resource'
| 'variable'
| 'resource_type'
@@ -55,10 +57,13 @@
| 'data_pipeline'
/** For 'trigger' kind, specifies the specific trigger type (routes, schedules, etc.) */
triggerKind?: string | undefined
/** For 'raw_app_file' kind: the file name/path, used to pick an
* extension-specific icon. */
path?: string | undefined
size?: number
}
let { kind, triggerKind = undefined, size = 16 }: Props = $props()
let { kind, triggerKind = undefined, path = undefined, size = 16 }: Props = $props()
// Map per-kind backend names (e.g. `kafka_trigger`) to the legacy short
// names the icon switch already handles, so we don't have to duplicate cases.
@@ -85,6 +90,8 @@
<BarsStaggered {size} class="text-teal-500" />
{:else if effectiveKind === 'app' || effectiveKind === 'raw_app'}
<LayoutDashboard {size} class="text-orange-500" />
{:else if effectiveKind === 'raw_app_file'}
<FileIcon name={path ?? ''} {size} />
{:else if effectiveKind === 'script'}
<Code2 {size} class="text-blue-500" />
{:else if effectiveKind === 'variable'}
@@ -163,7 +163,8 @@
{headerLeft}
hasDiff={aiChatManager.scriptEditorOptions &&
!!aiChatManager.scriptEditorOptions.lastDeployedCode &&
aiChatManager.scriptEditorOptions.lastDeployedCode !== aiChatManager.scriptEditorOptions.code}
aiChatManager.scriptEditorOptions.lastDeployedCode !==
aiChatManager.scriptEditorOptions.getCode()}
diffMode={aiChatManager.scriptEditorOptions?.diffMode ?? false}
{disabled}
{disabledMessage}
@@ -28,9 +28,8 @@
import { isActiveUserQuestion, type DisplayMessage } from './shared'
import type { ContextElement } from './context'
import ChatQuickActions from './ChatQuickActions.svelte'
import ProviderModelSelector from './ProviderModelSelector.svelte'
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
import AIChatSettingsMenu from './AIChatSettingsMenu.svelte'
import AIChatModelSettings from './AIChatModelSettings.svelte'
import ChatMode from './ChatMode.svelte'
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
@@ -253,14 +252,13 @@
const showTypingIndicator = $derived(aiChatManager.loading)
// `@` context picker is offered in modes that accept workspace/script/flow
// references (SCRIPT, FLOW, GLOBAL → workspace items + code blocks) or in
// APP mode (datatables, frontend files, etc.). Other modes (NAVIGATOR,
// ASK, API) don't accept @-context.
// The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items +
// code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there
// `@`-context is still invoked inline by typing `@` in the input, so the button
// is redundant. NAVIGATOR/ASK/API don't take @-context at all.
const showContextPicker = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.GLOBAL ||
aiChatManager.mode === AIMode.APP
)
@@ -909,8 +907,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#if aiChatManager.mode === AIMode.APP}
<DatatableCreationPolicy />
{/if}
<ProviderModelSelector />
<AIChatSettingsMenu />
<ContextUsageIndicator />
<AIChatModelSettings />
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
{#if appContext.inspectorElement}
@@ -958,9 +956,6 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</div>
{/if}
</div>
<div class="flex px-1 mt-1">
<ContextUsageIndicator />
</div>
</div>
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
<div class="px-2 mt-4">
@@ -113,6 +113,11 @@ const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3
// (panel teardown, save-and-clear) pass their own reason, so the queued-message
// flush can tell "the user wants to move on" from "the turn was torn down".
const USER_CANCEL_REASON = 'user_cancelled'
// Built-in `/compact` session command — summarizes the conversation locally
// instead of sending a turn to the model. Matched on the whole input so a
// regular message that merely mentions "/compact" mid-sentence is unaffected.
const COMPACT_COMMAND_NAME = 'compact'
const COMPACT_COMMAND_RE = /^\/compact\s*$/
const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode'
const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode'
const WEB_SEARCH_ERROR_HINT =
@@ -325,11 +330,29 @@ export class AIChatManager {
sessionId: string | undefined = undefined
// Workspace AI skills (name + description) advertised in the GLOBAL system
// prompt. Loaded asynchronously when entering GLOBAL mode; the system message
// is rebuilt once they resolve.
private globalSkills: AiSkillListItem[] = []
// prompt and surfaced as slash commands in session chat. Loaded
// asynchronously when entering GLOBAL mode; the system message is rebuilt
// once they resolve.
globalSkills = $state<AiSkillListItem[]>([])
private globalSkillsRefreshId = 0
// Built-in session-chat slash commands, listed in the command picker
// alongside workspace skills. Unlike a skill, `/compact` runs locally
// (compactManually) and never reaches the model; the submit path intercepts
// it first, so it shadows any workspace skill of the same name.
readonly sessionBuiltinCommands: AiSkillListItem[] = [
{ name: COMPACT_COMMAND_NAME, description: 'Summarize the conversation to free up context' }
]
// Built-ins followed by workspace skills, with any skill whose name collides
// with a built-in dropped: the picker keys leaves by name, so a duplicate
// would break its keyed list and ambiguous-resolve nav. Built-ins win — they
// already shadow same-named skills at execution (the submit interception).
sessionCommands: AiSkillListItem[] = $derived([
...this.sessionBuiltinCommands,
...this.globalSkills.filter((s) => !this.sessionBuiltinCommands.some((b) => b.name === s.name))
])
allowedModes: Record<AIMode, boolean> = $derived({
script:
this.flowAiChatHelpers === undefined &&
@@ -431,6 +454,64 @@ export class AIChatManager {
return freed
}
/**
* Core summarize + rewrite, shared by automatic and manual compaction. Sends
* the prefix to the summarizer, then replaces the summarized prefix with a
* single summary message in `messages` (as a user message) and
* `displayMessages` (as a `summary` boundary). Surviving tail user messages
* have their restart `index` re-based onto the new history: the summary
* occupies slot 0, so a tail user message that was at `keepFrom` lands at slot
* 1. `displayKeepFrom` is where the kept tail begins in `displayMessages`.
*
* Owns only the `compacting` flag and the history rewrite; callers own trigger
* policy (circuit breaker, gates) and persistence. Returns the outcome
* 'aborted' is a user Stop (history left untouched), distinct from 'error'.
*/
private runSummarization = async (
prefix: ChatCompletionMessageParam[],
tail: ChatCompletionMessageParam[],
keepFrom: number,
displayKeepFrom: number,
abortController: AbortController
): Promise<'ok' | 'empty' | 'aborted' | 'error'> => {
this.compacting = true
try {
const raw = await getNonStreamingCompletion(
[...prefix, { role: 'user', content: getCompactionSummaryPrompt() }],
abortController
)
const formatted = formatCompactSummary(raw ?? '')
if (!formatted) {
return 'empty'
}
this.messages = [{ role: 'user', content: buildSummaryMessageContent(formatted) }, ...tail]
// Replace the summarized display prefix with the boundary marker and
// re-base the surviving tail's restart indices (the summary occupies
// slot 0, so the tail now starts at slot 1).
this.displayMessages = [
{ role: 'summary', content: formatted },
...this.displayMessages
.slice(displayKeepFrom)
.map((m) => (m.role === 'user' ? { ...m, index: m.index - keepFrom + 1 } : m))
]
// The provider report described the pre-compaction history; the new
// history is much smaller, so clear it and let readers re-estimate.
this.contextUsage = undefined
return 'ok'
} catch (err) {
if (abortController.signal.aborted) {
return 'aborted'
}
console.error('Conversation summarization failed', err)
return 'error'
} finally {
this.compacting = false
}
}
/**
* Summary-based partial compaction. Summarizes the older PREFIX of the stored
* history into a single user message and keeps the recent tail verbatim,
@@ -505,45 +586,94 @@ export class AIChatManager {
return false
}
this.compacting = true
try {
const raw = await getNonStreamingCompletion(
[...prefix, { role: 'user', content: getCompactionSummaryPrompt() }],
abortController
)
const formatted = formatCompactSummary(raw ?? '')
if (!formatted) {
this.consecutiveCompactionFailures++
return false
}
this.messages = [{ role: 'user', content: buildSummaryMessageContent(formatted) }, ...tail]
// Replace the summarized display prefix with the boundary marker and
// re-base the surviving tail's restart indices (the summary occupies
// slot 0, so the tail now starts at slot 1).
this.displayMessages = [
{ role: 'summary', content: formatted },
...this.displayMessages
.slice(displayKeepFrom)
.map((m) => (m.role === 'user' ? { ...m, index: m.index - keepFrom + 1 } : m))
]
// The provider report described the pre-compaction history; the new
// history is much smaller, so clear it and let readers re-estimate.
this.contextUsage = undefined
const result = await this.runSummarization(
prefix,
tail,
keepFrom,
displayKeepFrom,
abortController
)
if (result === 'ok') {
this.consecutiveCompactionFailures = 0
return true
} catch (err) {
// A user Stop aborts the in-flight summary — that's a turn cancel, not a
// compaction failure, so it doesn't count toward the circuit breaker.
if (!abortController.signal.aborted) {
console.error('Conversation summarization failed', err)
this.consecutiveCompactionFailures++
}
// 'aborted' is a user Stop during the in-flight summary — a turn cancel, not
// a compaction failure, so it doesn't count toward the circuit breaker.
if (result === 'empty' || result === 'error') {
this.consecutiveCompactionFailures++
}
return false
}
/**
* Manual compaction (the `/compact` session command): summarize the ENTIRE
* stored history into a single summary message and keep nothing verbatim, so
* the next message continues from the summary alone. Unlike the automatic
* trigger it ignores the context-window budget, the circuit breaker, and the
* prefix-size gate the user asked for it explicitly and runs on its own
* abort controller so the Stop button (`cancel`) can interrupt the in-flight
* summary, leaving history untouched.
*/
compactManually = async (): Promise<void> => {
if (this.loading) {
return
}
// A summary round-trip only pays off once there's a prior exchange to fold
// in; a single message (or none) has nothing to compact.
if (this.messages.length < 2) {
sendUserToast('Nothing to compact yet.')
return
}
const abortController = new AbortController()
this.abortController = abortController
this.loading = true
let result: 'ok' | 'empty' | 'aborted' | 'error' = 'error'
try {
// Everything is the prefix, nothing is kept verbatim: keepFrom and
// displayKeepFrom point past the end so the kept tail is empty.
result = await this.runSummarization(
[...this.messages],
[],
this.messages.length,
this.displayMessages.length,
abortController
)
switch (result) {
case 'ok':
await this.historyManager.saveChat(
this.displayMessages,
this.messages,
this.contextUsage
)
sendUserToast('Conversation compacted.')
break
case 'empty':
sendUserToast(
'Compaction produced an empty summary — conversation left unchanged.',
true
)
break
case 'error':
sendUserToast('Failed to compact the conversation.', true)
break
// 'aborted' (user Stop): history untouched, no toast.
}
return false
} finally {
this.compacting = false
this.loading = false
}
// Flush a message typed while compaction ran. Mirrors the send-turn
// epilogue (loading gated its capture): auto-send after a successful
// compaction or a deliberate user cancel — the user is ready to move on —
// while a failed/empty compaction or a programmatic cancel leaves it queued.
if ((result === 'ok' || this.wasCancelledByUser()) && this.queuedMessage) {
const next = this.queuedMessage
this.queuedMessage = ''
const accepted = await this.sendRequest({ instructions: next })
if (accepted === false) {
this.queuedMessage = next
}
}
}
@@ -772,7 +902,7 @@ export class AIChatManager {
this.helpers = {
getScriptOptions: () => {
return {
code: this.scriptEditorOptions?.code ?? '',
code: this.scriptEditorOptions?.getCode() ?? '',
lang: lang,
path: this.scriptEditorOptions?.path ?? '',
args: this.scriptEditorOptions?.args ?? {}
@@ -844,7 +974,7 @@ export class AIChatManager {
// Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild
// the system message so the next chat-loop iteration advertises them. Ignore
// stale resolves so workspace changes cannot overwrite newer skills.
private refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => {
refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => {
const refreshId = ++this.globalSkillsRefreshId
const skills = await loadWorkspaceSkills(workspace)
if (refreshId !== this.globalSkillsRefreshId) {
@@ -859,6 +989,22 @@ export class AIChatManager {
}
}
private expandGlobalSkillCommand = (instructions: string): string => {
if (!this.isSessionChat || this.mode !== AIMode.GLOBAL || !instructions.startsWith('/')) {
return instructions
}
const match = /^\/([a-z0-9-]+)(?:\s+([\s\S]*))?$/.exec(instructions)
if (!match) {
return instructions
}
const skill = this.globalSkills.find((s) => s.name === match[1])
if (!skill) {
return instructions
}
const rest = match[2]?.trim()
return rest ? `Use the "${skill.name}" skill. ${rest}` : `Use the "${skill.name}" skill.`
}
canApplyCode = $derived(this.allowedModes.script && this.mode === AIMode.SCRIPT)
private changeModeTool = {
@@ -1246,6 +1392,20 @@ export class AIChatManager {
if (!this.instructions.trim()) {
return false
}
// Built-in `/compact` session command: summarize the conversation locally
// instead of sending a turn to the model. Intercepted here — before the
// beforeSend workspace commit, file regrants, and skill expansion — and not
// turned into a chat turn. Scoped to session chat GLOBAL mode, where the
// slash-command UI lives.
if (
this.isSessionChat &&
this.mode === AIMode.GLOBAL &&
COMPACT_COMMAND_RE.test(this.instructions.trim())
) {
this.instructions = ''
await this.compactManually()
return false
}
// Re-grant any locked File System Access handles within this send gesture, so the
// file tools can read the live files. requestPermission() needs a user gesture, and
// this runs before the first await/network call while the Send click is still active.
@@ -1355,6 +1515,10 @@ export class AIChatManager {
// The LLM gets the full pasted content; the display message above keeps
// the compact tokens + registry so the bubble can render/expand chips.
const oldInstructions = expanded(chatDraft(this.instructions, pastes))
const modelInstructions =
this.mode === AIMode.GLOBAL
? this.expandGlobalSkillCommand(oldInstructions)
: oldInstructions
this.instructions = ''
if (this.mode === AIMode.SCRIPT && !this.scriptEditorOptions && !options.lang) {
@@ -1387,7 +1551,7 @@ export class AIChatManager {
userMessage = prepareApiUserMessage(oldInstructions)
break
case AIMode.GLOBAL:
userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext, {
userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, {
workspace: get(workspaceStore)
})
break
@@ -1922,14 +2086,13 @@ export class AIChatManager {
lastDeployedCode: undefined,
lastSavedCode: undefined
}
return {
args: moduleState?.previewArgs ?? {},
error:
moduleState && !moduleState.previewSuccess
? getStringError(moduleState.previewResult)
: undefined,
code: module.value.content,
getCode: () => module.value.type === 'rawscript' ? module.value.content : '',
lang: module.value.language,
path: module.id,
...editorRelated
@@ -244,6 +244,31 @@ describe('AIChatManager global skills', () => {
expect(manager.systemMessage.content).toContain('child-skill')
expect(manager.systemMessage.content).not.toContain('parent-skill')
})
it('expands a leading slash skill command for the model while preserving the displayed text', async () => {
mocks.listAiSkills.mockResolvedValue([
{ name: 'review-code', description: 'review code for bugs' }
])
mocks.runChatLoop.mockImplementation(async (config: any) => {
const userMessage = config.messages[config.messages.length - 1]
expect(userMessage.content).toContain('Use the "review-code" skill. find bugs')
expect(userMessage.content).not.toContain('/review-code find bugs')
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages?.push(message)
return {
addedMessages: [message],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
const manager = new AIChatManager()
manager.isSessionChat = true
await manager.sendRequest({ instructions: '/review-code find bugs', mode: AIMode.GLOBAL })
expect(manager.displayMessages[0]?.content).toBe('/review-code find bugs')
})
})
describe('AIChatManager autonomy mode', () => {
@@ -915,7 +940,7 @@ describe('AIChatManager context compaction', () => {
// The request that went out begins with the summary user message, then the
// recent tail verbatim, then the new question.
const sent = mocks.runChatLoop.mock.calls[0][0].messages
const sent = mocks.runChatLoop.mock.calls[mocks.runChatLoop.mock.calls.length - 1][0].messages
expect(sent).toHaveLength(4)
expect(sent[0].role).toBe('user')
expect(sent[0].content).toContain('SUMMARY TEXT')
@@ -1004,6 +1029,178 @@ describe('AIChatManager context compaction', () => {
})
})
describe('AIChatManager manual compaction', () => {
const model = { provider: 'openai', model: 'gpt-4o' }
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
mocks.getCurrentModel.mockReturnValue(model)
mocks.tryGetCurrentModel.mockReturnValue(model)
// changeMode(GLOBAL) refreshes workspace skills; keep it a no-op here.
mocks.listAiSkills.mockResolvedValue([])
})
function seedExchange(manager: AIChatManager) {
manager.messages = [
{ role: 'user', content: 'q1' },
{ role: 'assistant', content: 'a1' },
{ role: 'user', content: 'q2' },
{ role: 'assistant', content: 'a2' }
]
manager.displayMessages = [
{ role: 'user', content: 'q1', index: 0 },
{ role: 'assistant', content: 'a1' },
{ role: 'user', content: 'q2', index: 2 },
{ role: 'assistant', content: 'a2' }
]
}
it('folds the whole history into a single summary boundary, keeping nothing verbatim', async () => {
mocks.getNonStreamingCompletion.mockResolvedValue('<summary>MANUAL SUMMARY</summary>')
const manager = new AIChatManager()
seedExchange(manager)
manager.contextUsage = 123
const saveChat = vi.spyOn(manager.historyManager, 'saveChat')
await manager.compactManually()
// The summarizer saw the entire history, then the summary instruction.
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
const summaryReq = mocks.getNonStreamingCompletion.mock.calls[0][0]
expect(summaryReq).toHaveLength(5)
expect(summaryReq[0].content).toBe('q1')
expect(summaryReq[3].content).toBe('a2')
expect(summaryReq[4].content).toContain('detailed summary')
// Nothing kept verbatim: messages collapse to just the summary user message.
expect(manager.messages).toHaveLength(1)
expect(manager.messages[0].role).toBe('user')
expect(manager.messages[0].content).toContain('MANUAL SUMMARY')
expect(manager.messages[0].content).toContain('continued from a previous conversation')
expect(manager.messages[0].content).not.toContain('<summary>')
// The transcript shows one summary boundary in place of the old bubbles.
expect(manager.displayMessages).toHaveLength(1)
expect(manager.displayMessages[0]).toMatchObject({ role: 'summary', content: 'MANUAL SUMMARY' })
expect(manager.contextUsage).toBeUndefined()
expect(saveChat).toHaveBeenCalled()
expect(mocks.sendUserToast).toHaveBeenCalledWith('Conversation compacted.')
expect(manager.loading).toBe(false)
expect(manager.compacting).toBe(false)
})
it('no-ops with a toast when there is nothing worth compacting', async () => {
const manager = new AIChatManager()
manager.messages = [{ role: 'user', content: 'only one' }]
await manager.compactManually()
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
expect(mocks.sendUserToast).toHaveBeenCalledWith('Nothing to compact yet.')
expect(manager.messages).toHaveLength(1)
})
it('leaves history untouched when the user stops mid-summary', async () => {
mocks.getNonStreamingCompletion.mockImplementation(async (_msgs: any, ac: AbortController) => {
ac.abort('user_cancelled')
throw new Error('aborted')
})
const manager = new AIChatManager()
seedExchange(manager)
await manager.compactManually()
expect(manager.messages).toHaveLength(4)
expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false)
// An abort is a user cancel, not a failure — no toast, no destructive change.
expect(mocks.sendUserToast).not.toHaveBeenCalled()
expect(manager.loading).toBe(false)
})
it('routes the /compact session command to manual compaction instead of the model', async () => {
mocks.getNonStreamingCompletion.mockResolvedValue('<summary>VIA COMMAND</summary>')
const manager = new AIChatManager()
manager.isSessionChat = true
seedExchange(manager)
const sent = await manager.sendRequest({ instructions: '/compact', mode: AIMode.GLOBAL })
// The command never became a chat turn...
expect(sent).toBe(false)
expect(mocks.runChatLoop).not.toHaveBeenCalled()
// ...it ran the summarizer and compacted in place, clearing the composer.
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
expect(manager.displayMessages[0]).toMatchObject({ role: 'summary', content: 'VIA COMMAND' })
expect(manager.instructions).toBe('')
})
it('auto-sends a message queued while compaction was running', async () => {
mocks.getNonStreamingCompletion.mockResolvedValue('<summary>S</summary>')
mocks.runChatLoop.mockImplementation(async (config: any) => {
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages?.push(message)
return {
addedMessages: [message],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
const manager = new AIChatManager()
manager.isSessionChat = true
manager.changeMode(AIMode.GLOBAL)
seedExchange(manager)
// A message typed while loading was true gets queued, not sent.
manager.queuedMessage = 'follow-up question'
await manager.compactManually()
// Compaction ran once, then the queued message went out as a real turn.
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
const sent = mocks.runChatLoop.mock.calls[0][0].messages
expect(sent[sent.length - 1].content).toContain('follow-up question')
expect(manager.queuedMessage).toBe('')
})
it('does not intercept /compact outside session chat', async () => {
mocks.runChatLoop.mockImplementation(async (config: any) => {
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages?.push(message)
return {
addedMessages: [message],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
const manager = new AIChatManager()
manager.isSessionChat = false
await manager.sendRequest({ instructions: '/compact', mode: AIMode.GLOBAL })
// Without the session-chat command surface, /compact is a normal message.
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
})
it('shadows a workspace skill that collides with a built-in command', () => {
const manager = new AIChatManager()
manager.globalSkills = [
{ name: 'compact', description: 'a workspace skill that happens to be named compact' },
{ name: 'review-code', description: 'review code for bugs' }
]
// Built-in `compact` comes first and the colliding skill is dropped, so the
// picker never renders two leaves with the same `skill:compact` key.
const names = manager.sessionCommands.map((c) => c.name)
expect(names).toEqual(['compact', 'review-code'])
expect(manager.sessionCommands[0].description).toBe(
'Summarize the conversation to free up context'
)
})
})
const assistantToolCall = (id: string): ChatCompletionMessageParam => ({
role: 'assistant',
content: '',
@@ -0,0 +1,456 @@
<script lang="ts">
import { ChevronDown, Check, User, Building2, Settings, ExternalLink } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import {
COPILOT_SESSION_MODEL_SETTING_NAME,
COPILOT_SESSION_PROVIDER_SETTING_NAME,
COPILOT_SESSION_REASONING_SETTING_NAME,
userStore,
workspaceStore
} from '$lib/stores'
import { storeLocalSetting, type Item } from '$lib/utils'
import {
copilotInfo,
copilotSessionModel,
getUserCustomPrompts,
setCopilotInfo,
setUserCustomPrompts
} from '$lib/aiStore'
import { WorkspaceService, type AIProvider, type AIProviderModel } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { base } from '$lib/base'
import AIPromptsModal from '$lib/components/settings/AIPromptsModal.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import {
getReasoningCapability,
resolveEffectiveReasoning,
REASONING_OFF,
type ReasoningProviderModel
} from '../reasoningRegistry'
const aiChatManager = getAiChatManager()
const AI_SETTINGS_HREF = `${base}/workspace_settings?tab=ai`
let providerModel = $derived(
($copilotSessionModel ??
$copilotInfo.defaultModel ??
$copilotInfo.aiModels[0] ?? {
model: 'No model',
provider: 'No provider'
}) as ReasoningProviderModel
)
let models = $derived($copilotInfo.aiModels)
let capability = $derived(
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
)
// Effective effort accounts for the default-on level on capable models.
let currentEffort = $derived(resolveEffectiveReasoning(providerModel))
// Slider stops: an off position only where the model can truly disable (else the
// provider would coerce it to the lowest level), then the provider-native levels.
let stops = $derived([...(capability.canDisable ? [REASONING_OFF] : []), ...capability.levels])
let currentStop = $derived(
providerModel.reasoning === REASONING_OFF
? REASONING_OFF
: (currentEffort ?? stops[stops.length - 1])
)
let stopIndex = $derived(Math.max(0, stops.indexOf(currentStop)))
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
let fillPct = $derived(stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0)
// Button suffix: the effort token, or 'off' when explicitly disabled. Omitted entirely
// for models with no reasoning support.
let effortLabel = $derived(capability.supported ? (currentEffort ?? REASONING_OFF) : undefined)
// The trigger label resizes when the effort changes (e.g. dragging the slider while the menu
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize would
// shift the popover. So we freeze the trigger to its width at open time and release it on close —
// no movement while open, and natural sizing (no reserved padding) the rest of the time.
let menuOpen = $state(false)
let triggerEl: HTMLElement | undefined = $state(undefined)
let lockedWidth = $state<number | undefined>(undefined)
$effect(() => {
if (menuOpen) {
if (lockedWidth === undefined && triggerEl) {
lockedWidth = triggerEl.getBoundingClientRect().width
}
} else {
lockedWidth = undefined
}
})
function selectModel(m: AIProviderModel) {
// Carry the effort onto the new model only if it supports that level ('off'
// only where the model can truly disable); otherwise drop it so the model's
// default applies.
const carried = providerModel.reasoning
const cap = getReasoningCapability(m.provider, m.model)
const keep =
carried === REASONING_OFF
? cap.canDisable
: carried !== undefined && cap.levels.includes(carried)
$copilotSessionModel = { ...m, ...(keep ? { reasoning: carried } : {}) }
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model)
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider)
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep ? carried : undefined)
}
function selectReasoning(value: string) {
const reasoning = value === REASONING_OFF ? REASONING_OFF : value
$copilotSessionModel = {
...providerModel,
provider: providerModel.provider as AIProvider,
reasoning
}
// Pin the current model selection so the reasoning choice persists with it.
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, providerModel.model)
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, providerModel.provider)
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, reasoning)
}
// ---- prompt parameters (User / Workspace custom prompts) ----
let mode = $derived(aiChatManager.mode)
let modalOpen = $state(false)
let modalScope = $state<'user' | 'workspace'>('user')
let customPrompts = $state<Record<string, string>>({})
let initialPrompt = $state('')
// Snapshot of the mode the modal was opened for. The live chat mode can change
// while the modal is open (mode selector sits behind it), so all edit/save/reset
// operations must key off this snapshot, not the reactive `mode`.
let activeMode = $state(aiChatManager.mode)
let isAdmin = $derived(Boolean($userStore?.is_admin || $userStore?.is_super_admin))
// True when the workspace has no AI providers of its own (it uses instance defaults).
// In that case the backend never makes workspace custom_prompts effective, so a saved
// workspace prompt would be dead config — mirror the settings page and surface it read-only.
let workspaceMissingProviders = $state(false)
let modalReadOnly = $derived(
modalScope === 'workspace' && (!isAdmin || workspaceMissingProviders)
)
let readOnlyReason = $derived(
modalScope === 'workspace' && isAdmin && workspaceMissingProviders
? 'This workspace uses instance AI defaults, so a workspace prompt would have no effect. Configure workspace AI providers in AI settings first.'
: undefined
)
let hasChanges = $derived((customPrompts[activeMode] ?? '') !== initialPrompt)
function openUserPrompt() {
activeMode = mode
initialPrompt = getUserCustomPrompts()[activeMode] ?? ''
customPrompts = { [activeMode]: initialPrompt }
modalScope = 'user'
modalOpen = true
}
// Seed from the same source save() writes to (the raw workspace ai_config) and detect
// whether the workspace has its own providers in the same fetch. Non-admins can't read
// raw settings, so they see the effective prompt from copilotInfo (read-only).
async function openWorkspacePrompt() {
activeMode = mode
modalScope = 'workspace'
workspaceMissingProviders = false
if (!isAdmin) {
initialPrompt = $copilotInfo.customPrompts?.[activeMode] ?? ''
} else {
const workspace = $workspaceStore
try {
const settings = workspace ? await WorkspaceService.getSettings({ workspace }) : undefined
const providers = settings?.ai_config?.providers ?? {}
workspaceMissingProviders = Object.keys(providers).length === 0
initialPrompt = settings?.ai_config?.custom_prompts?.[activeMode] ?? ''
} catch (err) {
sendUserToast(`Failed to load workspace AI prompt: ${err}`, true)
initialPrompt = $copilotInfo.customPrompts?.[activeMode] ?? ''
}
}
customPrompts = { [activeMode]: initialPrompt }
modalOpen = true
}
function reset() {
customPrompts = { [activeMode]: initialPrompt }
}
async function save() {
const value = (customPrompts[activeMode] ?? '').trim()
if (modalScope === 'user') {
const prompts = getUserCustomPrompts()
if (value) {
prompts[activeMode] = value
} else {
delete prompts[activeMode]
}
setUserCustomPrompts(prompts)
initialPrompt = value
customPrompts = { [activeMode]: value }
sendUserToast('User AI prompt saved')
return
}
const workspace = $workspaceStore
if (!workspace) return
try {
// Saving prompts requires a full ai_config round-trip; fetch the current
// config so we don't clobber providers/models/etc.
const settings = await WorkspaceService.getSettings({ workspace })
const config = settings.ai_config ?? {}
const custom_prompts = { ...(config.custom_prompts ?? {}) }
if (value) {
custom_prompts[activeMode] = value
} else {
delete custom_prompts[activeMode]
}
const response = await WorkspaceService.editCopilotConfig({
workspace,
requestBody: { ...config, custom_prompts }
})
setCopilotInfo(response.effective_ai_config)
initialPrompt = value
customPrompts = { [activeMode]: value }
sendUserToast('Workspace AI prompt saved')
} catch (err) {
sendUserToast(`Failed to save workspace AI prompt: ${err}`, true)
// Re-throw so AIPromptsModal keeps the modal open on a failed save.
throw err
}
}
// Prompt parameters, surfaced as a melt submenu (hover-opens and is floating-positioned,
// so it flips on screen edges instead of overflowing). The menu keeps itself open on
// item click (closeOnItemClick=false), so these actions close it explicitly via `close`.
function paramItems(close: () => void): Item {
return {
displayName: 'Parameters',
icon: Settings,
submenuItems: [
{
displayName: 'User prompt',
icon: User,
action: () => {
close()
openUserPrompt()
}
},
{
displayName: 'Workspace prompt',
icon: Building2,
action: () => {
close()
openWorkspacePrompt()
}
},
{
displayName: 'AI settings',
icon: Settings,
href: AI_SETTINGS_HREF,
hrefTarget: '_blank',
separatorTop: true,
hide: !isAdmin,
extra: externalLinkIcon
}
]
}
}
// Keep the slider's pointer events from bubbling to the enclosing melt item: melt's
// roving focus blurs the focused element on pointermove, which would abort the native
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
function isolatePointer(node: HTMLElement) {
const stop = (e: Event) => e.stopPropagation()
node.addEventListener('pointerdown', stop)
node.addEventListener('pointermove', stop)
return {
destroy() {
node.removeEventListener('pointerdown', stop)
node.removeEventListener('pointermove', stop)
}
}
}
// Adjust the reasoning effort with the arrow keys while the Thinking item is focused.
function adjustEffort(e: KeyboardEvent) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const next = Math.min(
stops.length - 1,
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
)
selectReasoning(stops[next])
}
</script>
{#snippet externalLinkIcon()}
<ExternalLink size={14} class="shrink-0 text-secondary" />
{/snippet}
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
<div
bind:this={triggerEl}
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
endIcon={{ icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title="Model & reasoning settings"
>
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{providerModel.model}</span>
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
</span>
</Button>
</div>
{/snippet}
{#snippet menu({ item, builders, close })}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
<div class="my-1 border-t border-border-light"></div>
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
<div class="max-h-48 overflow-y-auto">
{#each models as m (m.provider + m.model)}
<MenuItem
{item}
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
onClick={() => selectModel(m)}
>
<span class="truncate grow min-w-0">{m.model}</span>
{#if m.model === providerModel.model && m.provider === providerModel.provider}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/each}
</div>
<div class="my-1 border-t border-border-light"></div>
{#if capability.supported}
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
up/down navigation), and so hovering it takes the highlight off the Parameters
trigger. Left/right adjust the effort; the slider's input handler also drives it. -->
<MenuItemWrapper {item} onKeydown={adjustEffort} class="block group">
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
<span class="text-2xs text-secondary tabular-nums">{currentStop}</span>
</div>
{#if stops.length > 1}
<!-- Only the slider area reflects the item's highlight, not the header. -->
<div
class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover"
>
<input
type="range"
min="0"
max={stops.length - 1}
step="1"
value={stopIndex}
style="--fill: {fillPct}%"
oninput={(e) => selectReasoning(stops[+e.currentTarget.value])}
use:isolatePointer
class="lean-range no-default-style w-full"
aria-label="Reasoning effort"
/>
</div>
{/if}
</MenuItemWrapper>
{:else}
<!-- Reasoning unsupported: keep the section but show it disabled with a reason,
rather than hiding it. Not a melt item, so it's skipped by keyboard navigation. -->
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
<div class="text-2xs text-tertiary mt-0.5">Not supported by this model</div>
</div>
{/if}
</div>
{/snippet}
</DropdownV2>
<AIPromptsModal
bind:open={modalOpen}
bind:customPrompts
scope={modalScope}
modes={[activeMode]}
readOnly={modalReadOnly}
{readOnlyReason}
onSave={modalReadOnly ? undefined : save}
onReset={reset}
{hasChanges}
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
target="body"
fixedHeight="sm"
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
/>
<style>
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
rules — so they are wrapped in :global (the class is unique to this component). */
.lean-range {
-webkit-appearance: none;
appearance: none;
height: 10px;
margin: 0;
padding: 0;
/* override the global `input { background-color: ... !important }` so only the
thin track shows, not a full-height band behind it */
background-color: transparent !important;
cursor: pointer;
outline: none;
}
.lean-range:focus,
.lean-range:focus-visible {
outline: none;
}
:global(.lean-range::-webkit-slider-runnable-track) {
height: 3px;
border-radius: 9999px;
background: linear-gradient(
to right,
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
rgb(var(--color-surface-secondary)) var(--fill, 0%)
);
}
:global(.lean-range::-webkit-slider-thumb) {
-webkit-appearance: none;
appearance: none;
margin-top: -3.5px;
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-track) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-secondary));
}
:global(.lean-range::-moz-range-progress) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-thumb) {
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
</style>
@@ -1,186 +0,0 @@
<script lang="ts">
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { Building2, ExternalLink, Settings, User } from 'lucide-svelte'
import AIPromptsModal from '$lib/components/settings/AIPromptsModal.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import {
copilotInfo,
getUserCustomPrompts,
setCopilotInfo,
setUserCustomPrompts
} from '$lib/aiStore'
import { userStore, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import type { Item } from '$lib/utils'
import { base } from '$lib/base'
const AI_SETTINGS_HREF = `${base}/workspace_settings?tab=ai`
// Quick access to the AI prompt preferences (user + workspace) for the
// current chat mode, surfaced next to the model selector in the AI chat.
const aiChatManager = getAiChatManager()
let mode = $derived(aiChatManager.mode)
let modalOpen = $state(false)
let modalScope = $state<'user' | 'workspace'>('user')
let customPrompts = $state<Record<string, string>>({})
let initialPrompt = $state('')
// Snapshot of the mode the modal was opened for. The live chat mode can change
// while the modal is open (mode selector sits behind it), so all edit/save/reset
// operations must key off this snapshot, not the reactive `mode`.
let activeMode = $state(aiChatManager.mode)
let isAdmin = $derived(Boolean($userStore?.is_admin || $userStore?.is_super_admin))
// True when the workspace has no AI providers of its own (it uses instance defaults).
// In that case the backend never makes workspace custom_prompts effective
// (get_copilot_info returns the instance config verbatim), so a saved workspace prompt
// would be dead config — mirror the settings page and surface the prompt read-only.
let workspaceMissingProviders = $state(false)
// Workspace prompts are admin-only to edit; non-admins (and admins on a workspace
// without its own providers) get a read-only view.
let modalReadOnly = $derived(
modalScope === 'workspace' && (!isAdmin || workspaceMissingProviders)
)
let readOnlyReason = $derived(
modalScope === 'workspace' && isAdmin && workspaceMissingProviders
? 'This workspace uses instance AI defaults, so a workspace prompt would have no effect. Configure workspace AI providers in AI settings first.'
: undefined
)
let hasChanges = $derived((customPrompts[activeMode] ?? '') !== initialPrompt)
function openUserPrompt() {
activeMode = mode
initialPrompt = getUserCustomPrompts()[activeMode] ?? ''
customPrompts = { [activeMode]: initialPrompt }
modalScope = 'user'
modalOpen = true
}
// Seed from the same source save() writes to (the raw workspace ai_config) and detect
// whether the workspace has its own providers in the same fetch. Non-admins can't read
// raw settings, so they see the effective prompt from copilotInfo (read-only).
async function openWorkspacePrompt() {
activeMode = mode
modalScope = 'workspace'
workspaceMissingProviders = false
if (!isAdmin) {
initialPrompt = $copilotInfo.customPrompts?.[activeMode] ?? ''
} else {
const workspace = $workspaceStore
try {
const settings = workspace ? await WorkspaceService.getSettings({ workspace }) : undefined
const providers = settings?.ai_config?.providers ?? {}
workspaceMissingProviders = Object.keys(providers).length === 0
initialPrompt = settings?.ai_config?.custom_prompts?.[activeMode] ?? ''
} catch (err) {
sendUserToast(`Failed to load workspace AI prompt: ${err}`, true)
initialPrompt = $copilotInfo.customPrompts?.[activeMode] ?? ''
}
}
customPrompts = { [activeMode]: initialPrompt }
modalOpen = true
}
function reset() {
customPrompts = { [activeMode]: initialPrompt }
}
async function save() {
const value = (customPrompts[activeMode] ?? '').trim()
if (modalScope === 'user') {
const prompts = getUserCustomPrompts()
if (value) {
prompts[activeMode] = value
} else {
delete prompts[activeMode]
}
setUserCustomPrompts(prompts)
initialPrompt = value
// Sync the editor to the trimmed value so hasChanges resets to false.
customPrompts = { [activeMode]: value }
sendUserToast('User AI prompt saved')
return
}
const workspace = $workspaceStore
if (!workspace) return
try {
// Saving prompts requires a full ai_config round-trip; fetch the
// current config so we don't clobber providers/models/etc.
const settings = await WorkspaceService.getSettings({ workspace })
const config = settings.ai_config ?? {}
const custom_prompts = { ...(config.custom_prompts ?? {}) }
if (value) {
custom_prompts[activeMode] = value
} else {
delete custom_prompts[activeMode]
}
const response = await WorkspaceService.editCopilotConfig({
workspace,
requestBody: { ...config, custom_prompts }
})
// Editing is gated on the workspace having its own providers, so effective_ai_config
// equals the saved config (it only falls back to the instance config when the
// workspace has none) and already carries the custom_prompts we just wrote.
setCopilotInfo(response.effective_ai_config)
initialPrompt = value
// Sync the editor to the trimmed value so hasChanges resets to false.
customPrompts = { [activeMode]: value }
sendUserToast('Workspace AI prompt saved')
} catch (err) {
sendUserToast(`Failed to save workspace AI prompt: ${err}`, true)
// Re-throw so AIPromptsModal keeps the modal open on a failed save.
throw err
}
}
let items = $derived<Item[]>([
{ displayName: 'User prompt', icon: User, action: openUserPrompt },
{ displayName: 'Workspace prompt', icon: Building2, action: openWorkspacePrompt },
{
displayName: 'AI settings',
icon: Settings,
href: AI_SETTINGS_HREF,
hrefTarget: '_blank',
separatorTop: true,
hide: !isAdmin,
extra: externalLinkIcon
}
])
</script>
{#snippet externalLinkIcon()}
<ExternalLink size={14} class="shrink-0 text-secondary" />
{/snippet}
<DropdownV2 {items} placement="bottom-end" fixedHeight={false}>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
iconOnly
startIcon={{ icon: Settings }}
btnClasses="text-secondary"
title="AI prompt settings"
/>
{/snippet}
</DropdownV2>
<AIPromptsModal
bind:open={modalOpen}
bind:customPrompts
scope={modalScope}
modes={[activeMode]}
readOnly={modalReadOnly}
{readOnlyReason}
onSave={modalReadOnly ? undefined : save}
onReset={reset}
{hasChanges}
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
target="body"
fixedHeight="sm"
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
/>
@@ -101,6 +101,7 @@
</Splitpanes>
{:else}
<div
id="content"
class={classNames(
'flex-1 min-h-0 flex flex-col',
noBorder || $userStore?.operator || isMobile ? '' : isCollapsed ? 'pl-12' : 'pl-40',
@@ -0,0 +1,68 @@
<script lang="ts">
import { Sparkles } from 'lucide-svelte'
import DrillPicker from '$lib/components/DrillPicker.svelte'
import type { DrillLeaf, DrillNode } from '$lib/components/drillPicker'
import type { AiSkillListItem } from './global/core'
interface Props {
skills: AiSkillListItem[]
onSelect: (skill: AiSkillListItem) => void
setShowing?: (showing: boolean) => void
externalFilter?: string
autoFocus?: boolean
}
let { skills, onSelect, setShowing, externalFilter, autoFocus = true }: Props = $props()
type DrillPickerHandle = {
handleKeydown: (e: KeyboardEvent) => void
}
let inner = $state<DrillPickerHandle | undefined>(undefined)
const tree = $derived<DrillNode<AiSkillListItem>[]>(
skills.map((skill) => ({
type: 'leaf' as const,
key: `skill:${skill.name}`,
label: `/${skill.name}`,
secondary: skill.description,
searchableText: `${skill.name} ${skill.description}`,
data: skill
}))
)
export function handleKeydown(e: KeyboardEvent) {
inner?.handleKeydown(e)
}
function handlePick(leaf: DrillLeaf<AiSkillListItem>) {
onSelect(leaf.data)
}
function onDocumentKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && !e.defaultPrevented) {
setShowing?.(false)
}
}
$effect(() => {
document.addEventListener('keydown', onDocumentKeydown)
return () => document.removeEventListener('keydown', onDocumentKeydown)
})
</script>
{#snippet skillIcon(_leaf: DrillLeaf<AiSkillListItem>)}
<Sparkles size={12} class="shrink-0 text-tertiary" />
{/snippet}
<div class="w-[min(340px,calc(100vw-20px))] max-h-64 overflow-hidden">
<DrillPicker
bind:this={inner}
{tree}
onPick={handlePick}
{externalFilter}
{autoFocus}
leafIcon={skillIcon}
flush
/>
</div>
@@ -11,7 +11,7 @@ import type { ExtendedOpenFlow } from '$lib/components/flows/types'
export interface ScriptOptions {
lang: ScriptLang | 'bunnative'
code: string
getCode: () => string
error: string | undefined
args: Record<string, any>
path: string | undefined
@@ -192,7 +192,7 @@ export default class ContextManager {
{
type: 'code',
title: this.getContextCodePath(scriptOptions) ?? '',
content: scriptOptions.code,
content: scriptOptions.getCode(),
lang: scriptOptions.lang
}
]
@@ -209,22 +209,22 @@ export default class ContextManager {
}
}
if (scriptOptions.lastSavedCode && scriptOptions.lastSavedCode !== scriptOptions.code) {
if (scriptOptions.lastSavedCode && scriptOptions.lastSavedCode !== scriptOptions.getCode()) {
newAvailableContext.push({
type: 'diff',
title: 'diff_with_last_saved_draft', // can't use spaces in the title, because it will break the word match in the context text area hightlighting logic
content: scriptOptions.lastSavedCode ?? '',
diff: diffLines(scriptOptions.lastSavedCode ?? '', scriptOptions.code),
diff: diffLines(scriptOptions.lastSavedCode ?? '', scriptOptions.getCode()),
lang: scriptOptions.lang
})
}
if (scriptOptions.lastDeployedCode && scriptOptions.lastDeployedCode !== scriptOptions.code) {
if (scriptOptions.lastDeployedCode && scriptOptions.lastDeployedCode !== scriptOptions.getCode()) {
newAvailableContext.push({
type: 'diff',
title: 'diff_with_last_deployed_version',
content: scriptOptions.lastDeployedCode ?? '',
diff: diffLines(scriptOptions.lastDeployedCode ?? '', scriptOptions.code),
diff: diffLines(scriptOptions.lastDeployedCode ?? '', scriptOptions.getCode()),
lang: scriptOptions.lang
})
}
@@ -251,7 +251,7 @@ export default class ContextManager {
{
type: 'code',
title: this.getContextCodePath(scriptOptions) ?? '',
content: scriptOptions.code,
content: scriptOptions.getCode(),
lang: scriptOptions.lang,
deletable: false
},
@@ -277,7 +277,7 @@ export default class ContextManager {
newSelectedContext = newSelectedContext
.filter(
(c) =>
(c.type === 'code_piece' && scriptOptions.code.includes(c.content)) ||
(c.type === 'code_piece' && scriptOptions.getCode().includes(c.content)) ||
c.type === 'code' ||
// Workspace references are user-picked via @-mention and not in
// availableContext; preserve so badges survive editor refreshes.
@@ -289,7 +289,7 @@ export default class ContextManager {
if (c.type === 'code') {
return {
...c,
content: scriptOptions.code,
content: scriptOptions.getCode(),
title: this.getContextCodePath(scriptOptions)
}
}
@@ -403,7 +403,7 @@ export default class ContextManager {
type: 'diff' as const,
title: 'diff_with_last_deployed_version',
content: this.scriptOptions.lastDeployedCode ?? '',
diff: diffLines(this.scriptOptions.lastDeployedCode ?? '', this.scriptOptions.code),
diff: diffLines(this.scriptOptions.lastDeployedCode ?? '', this.scriptOptions.getCode()),
lang: this.scriptOptions.lang
}
]
@@ -2,6 +2,8 @@
import autosize from '$lib/autosize'
import { tick } from 'svelte'
import type { ContextElement } from './context'
import { AIMode } from './AIChatManager.svelte'
import ChatCommandPicker from './ChatCommandPicker.svelte'
import ChatContextPicker from './ChatContextPicker.svelte'
import Portal from '$lib/components/Portal.svelte'
import { zIndexes } from '$lib/zIndexes'
@@ -68,11 +70,22 @@
let showContextTooltip = $state(false)
let contextTooltipWord = $state('')
let showCommandTooltip = $state(false)
let commandTooltipWord = $state('')
let textarea = $state<HTMLTextAreaElement | undefined>(undefined)
let tooltipElement = $state<HTMLDivElement | undefined>(undefined)
let chatContextPicker: ChatContextPicker | undefined = $state()
let chatCommandPicker: ChatCommandPicker | undefined = $state()
let commandSkillsRefreshInFlight = false
// Virtual reference anchored at the `@` that opened the mention (not the
const commandSkills = $derived(
aiChatManager.mode === AIMode.GLOBAL && aiChatManager.isSessionChat
? aiChatManager.sessionCommands
: []
)
const activeTooltipWord = $derived(showContextTooltip ? contextTooltipWord : commandTooltipWord)
// Virtual reference anchored at the trigger that opened the picker (not the
// caret), so the picker stays put while the user types the query.
// svelte-floating-ui's `createVirtualElement` takes a raw ClientRect and
// wraps it in a function internally — re-`update()` on each anchor move.
@@ -526,19 +539,33 @@
showContextTooltip = false
}
function refreshCommandSkills() {
if (commandSkillsRefreshInFlight) return
commandSkillsRefreshInFlight = true
void aiChatManager.refreshGlobalSkills().finally(() => {
commandSkillsRefreshInFlight = false
})
}
function getCommandFilter(text: string): string | undefined {
if (aiChatManager.mode !== AIMode.GLOBAL || !aiChatManager.isSessionChat) return undefined
const match = /^\/([a-z0-9-]*)$/.exec(text)
return match?.[1]
}
function updateAnchorRect() {
if (!textarea) return
const triggerWord = activeTooltipWord
if (!triggerWord) return
try {
// Index of the `@` that started the current mention. handleInput
// only opens the picker when `contextTooltipWord` (= `@xxx`) is the
// LAST whitespace-separated word in `value`, so the `@` always sits
// at `value.length - contextTooltipWord.length`.
const atIndex = value.length - contextTooltipWord.length
const coords = getCaretCoordinates(textarea, atIndex)
// Inline `@` anchors to the last word; slash commands only open when
// `/...` is the whole input, so the trigger sits at index 0.
const triggerIndex = triggerWord.startsWith('/') ? 0 : value.length - triggerWord.length
const coords = getCaretCoordinates(textarea, triggerIndex)
const rect = textarea.getBoundingClientRect()
// getCaretCoordinates returns content-relative coords; subtract the
// textarea's own scroll so the anchor tracks the `@` once the input is
// capped (max-height) and scrolls internally.
// textarea's own scroll so the anchor tracks the trigger once the input
// is capped (max-height) and scrolls internally.
anchorRect = new DOMRect(
rect.left + coords.left - textarea.scrollLeft,
rect.top + coords.top - textarea.scrollTop,
@@ -558,6 +585,19 @@
function handleInput(e: Event) {
textarea = e.target as HTMLTextAreaElement
const commandFilter = getCommandFilter(value)
if (commandFilter !== undefined) {
const wasShowing = showCommandTooltip
showCommandTooltip = true
commandTooltipWord = `/${commandFilter}`
showContextTooltip = false
contextTooltipWord = ''
if (!wasShowing) refreshCommandSkills()
return
}
showCommandTooltip = false
commandTooltipWord = ''
const words = value.split(/\s+/)
const lastWord = words[words.length - 1]
@@ -574,6 +614,12 @@
}
}
function handleCommandSelection(skill: { name: string }) {
value = `/${skill.name} `
showCommandTooltip = false
setTimeout(() => textarea?.focus(), 0)
}
function handleKeyDown(e: KeyboardEvent) {
// Pass to parent first if provided
if (onKeyDown) {
@@ -585,6 +631,22 @@
return
}
if (showCommandTooltip) {
if (
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'Enter' ||
e.key === 'Tab' ||
e.key === 'Escape'
) {
chatCommandPicker?.handleKeydown(e)
}
if (e.key === 'Enter') {
e.preventDefault()
}
return
}
if (showContextTooltip) {
// Forward navigation keys to the picker so the textarea-focused
// user can drive it. The picker preventDefault/stopPropagation's
@@ -622,11 +684,11 @@
}
$effect(() => {
// Re-track on every value change. The `@` position can shift when the
// user adds/deletes text BEFORE it (line wrap, etc.); the picker should
// follow. floating-ui's autoUpdate only fires on scroll/resize.
// Re-track on every value change. The trigger position can shift when
// the user adds/deletes text before it (line wrap, etc.); the picker
// should follow. floating-ui's autoUpdate only fires on scroll/resize.
void value
if (showContextTooltip) updateAnchorRect()
if (showContextTooltip || showCommandTooltip) updateAnchorRect()
})
$effect(() => {
@@ -700,9 +762,9 @@
ondragstart={handlePasteDragStart}
onscroll={(e) => {
scrollTop = e.currentTarget.scrollTop
// Keep the `@` picker pinned to its anchor while the input scrolls
// Keep the picker pinned to its anchor while the input scrolls
// internally (autoUpdate can't observe a virtual ref's scroll).
if (showContextTooltip) updateAnchorRect()
if (showContextTooltip || showCommandTooltip) updateAnchorRect()
}}
onblur={() => {
setTimeout(() => {
@@ -711,6 +773,7 @@
return
}
showContextTooltip = false
showCommandTooltip = false
}, 200)
}}
{placeholder}
@@ -724,7 +787,7 @@
></textarea>
</div>
{#if showContextTooltip}
{#if showContextTooltip || showCommandTooltip}
<Portal target="body">
<div
bind:this={tooltipElement}
@@ -732,33 +795,46 @@
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md shadow-lg overflow-hidden"
style="z-index: {zIndexes.tooltip};"
>
<ChatContextPicker
bind:this={chatContextPicker}
{availableContext}
{selectedContext}
onSelect={(element) => {
handleContextSelection(element)
}}
onSelectWorkspaceItem={(element) => {
onAddContext(element)
updateInstructionsWithContext(element)
showContextTooltip = false
setTimeout(() => textarea?.focus(), 0)
}}
externalFilter={contextTooltipWord.slice(1)}
autoFocus={false}
setShowing={(showing) => {
showContextTooltip = showing
}}
onSelectFile={(name) => {
// Replace the in-progress `@word` with the chosen mention (bracketed if the
// filename has spaces, so the highlighter captures it whole).
const index = value.lastIndexOf('@')
value = (index !== -1 ? value.substring(0, index) : value) + `${formatMention(name)} `
showContextTooltip = false
setTimeout(() => textarea?.focus(), 0)
}}
/>
{#if showCommandTooltip}
<ChatCommandPicker
bind:this={chatCommandPicker}
skills={commandSkills}
onSelect={handleCommandSelection}
externalFilter={commandTooltipWord.slice(1)}
autoFocus={false}
setShowing={(showing) => {
showCommandTooltip = showing
}}
/>
{:else}
<ChatContextPicker
bind:this={chatContextPicker}
{availableContext}
{selectedContext}
onSelect={(element) => {
handleContextSelection(element)
}}
onSelectWorkspaceItem={(element) => {
onAddContext(element)
updateInstructionsWithContext(element)
showContextTooltip = false
setTimeout(() => textarea?.focus(), 0)
}}
externalFilter={contextTooltipWord.slice(1)}
autoFocus={false}
setShowing={(showing) => {
showContextTooltip = showing
}}
onSelectFile={(name) => {
// Replace the in-progress `@word` with the chosen mention (bracketed if the
// filename has spaces, so the highlighter captures it whole).
const index = value.lastIndexOf('@')
value = (index !== -1 ? value.substring(0, index) : value) + `${formatMention(name)} `
showContextTooltip = false
setTimeout(() => textarea?.focus(), 0)
}}
/>
{/if}
</div>
</Portal>
{/if}
@@ -2,6 +2,7 @@
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
import { getKnownModelContextWindow } from '../modelConfig'
import { getAiChatManager } from './aiChatManagerContext'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
const aiChatManager = getAiChatManager()
@@ -19,6 +20,21 @@
// the user can watch context grow toward the compaction threshold.
let visible = $derived(usedTokens > 0 && aiChatManager.messages.length > 0)
// Compaction triggers at 80% of the window (COMPACTION_TRIGGER_RATIO); the
// gauge fills toward that point and turns red once it is reached.
const COMPACTION_TRIGGER_RATIO = 0.8
let ratio = $derived(contextWindow ? Math.min(usedTokens / contextWindow, 1) : undefined)
let fillPct = $derived(ratio !== undefined ? Math.round(ratio * 100) : undefined)
let fillClass = $derived(
ratio === undefined
? 'bg-tertiary'
: ratio >= COMPACTION_TRIGGER_RATIO
? 'bg-red-500'
: ratio >= COMPACTION_TRIGGER_RATIO * 0.75
? 'bg-amber-500'
: 'bg-surface-accent-primary'
)
function formatTokenCount(tokens: number): string {
if (tokens >= 1_000_000) {
return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
@@ -31,9 +47,35 @@
</script>
{#if visible}
<span class="text-[0.6rem] text-tertiary tabular-nums" aria-label="Context window usage">
Context usage: ~{formatTokenCount(usedTokens)}{contextWindow
? ` / ${formatTokenCount(contextWindow)}`
: ''}
</span>
<Tooltip small placement="top">
<!-- Only a meter when we know the window: it's a 0100% reading. With an unknown
window there's no max to measure against, so it's a plain labeled indicator
(the bar is decorative/full and the token count lives in the tooltip). -->
<div
class="flex items-center h-5"
aria-label="Context window usage"
role={fillPct !== undefined ? 'meter' : undefined}
aria-valuenow={fillPct}
aria-valuemin={fillPct !== undefined ? 0 : undefined}
aria-valuemax={fillPct !== undefined ? 100 : undefined}
>
<div class="w-8 h-1.5 rounded-full bg-surface-secondary overflow-hidden">
<div class="h-full rounded-full transition-all {fillClass}" style="width: {fillPct ?? 100}%"
></div>
</div>
</div>
{#snippet text()}
<div class="text-xs whitespace-nowrap">
<p class="font-semibold">Context usage</p>
<p class="mt-1 tabular-nums">
~{formatTokenCount(usedTokens)}{contextWindow
? ` / ${formatTokenCount(contextWindow)}`
: ''}{fillPct !== undefined ? ` (${fillPct}%)` : ''}
</p>
{#if ratio !== undefined && ratio >= COMPACTION_TRIGGER_RATIO}
<p class="mt-1 text-tertiary">History will be compacted soon to free up space.</p>
{/if}
</div>
{/snippet}
</Tooltip>
{/if}
@@ -34,6 +34,37 @@ chronological thinking the model should not keep
expect(formatCompactSummary(raw)).toBe('a\n\nb')
})
it('keeps the summary content when <summary> has no closing tag', () => {
const raw = '<summary>\n1. Primary Request and Intent: build the thing\n2. Pending Tasks: none'
const formatted = formatCompactSummary(raw)
expect(formatted).not.toContain('<summary>')
expect(formatted).toContain('Primary Request and Intent: build the thing')
expect(formatted).toContain('Pending Tasks: none')
})
it('drops the analysis scratchpad even when <summary> is left unclosed', () => {
const raw =
'<analysis>\nchronological thinking the model should not keep\n</analysis>\n<summary>\nthe real summary'
const formatted = formatCompactSummary(raw)
expect(formatted).not.toContain('chronological thinking')
expect(formatted).not.toContain('<analysis>')
expect(formatted).not.toContain('<summary>')
expect(formatted).toBe('the real summary')
})
it('strips an orphaned closing summary tag', () => {
expect(formatCompactSummary('plain summary</summary>')).toBe('plain summary')
})
it('does not leak analysis scratchpad that mentions a literal <summary> tag', () => {
const raw = `<analysis>scratchpad mentions <summary> before output</analysis>
<summary>real summary</summary>`
const formatted = formatCompactSummary(raw)
expect(formatted).toBe('real summary')
expect(formatted).not.toContain('scratchpad')
expect(formatted).not.toContain('before output')
})
it('strips every analysis block, not just the first, when the summary is untagged', () => {
const raw = '<analysis>first</analysis>\nkept one\n<analysis>second</analysis>\nkept two'
const formatted = formatCompactSummary(raw)
@@ -100,13 +100,27 @@ export function getCompactionSummaryPrompt(): string {
* well-formed-but-untagged summary is still usable.
*/
export function formatCompactSummary(raw: string): string {
// Strip the analysis scratchpad first: it precedes the summary and may itself
// mention <summary>/<analysis> tokens that would otherwise be mistaken for the
// real summary boundary.
let formatted = raw.replace(/<analysis>[\s\S]*?<\/analysis>/gi, '')
const summaryMatch = formatted.match(/<summary>([\s\S]*?)<\/summary>/i)
if (summaryMatch) {
formatted = (summaryMatch[1] ?? '').trim()
} else {
// A truncated response or a weaker model sometimes opens <summary> without
// closing it. The text after the opener is still the summary, so keep it
// rather than leak the bare tag.
const openIdx = formatted.search(/<summary>/i)
if (openIdx !== -1) {
formatted = formatted.slice(openIdx)
}
}
// An orphaned opener or closer left by either branch must never reach the user.
formatted = formatted.replace(/<\/?(?:analysis|summary)>/gi, '')
// Collapse the blank-line runs left behind by stripping the analysis block.
return formatted.replace(/\n{3,}/g, '\n\n').trim()
}
@@ -2757,6 +2757,18 @@ describe('prepareGlobalSystemMessage', () => {
expect(content).not.toContain('frontend AI draft store')
})
it('honors user-supplied shared folder paths without asking first', () => {
const content = prepareGlobalSystemMessage(undefined, {
user: { username: 'admin', is_admin: true, folders: ['evals'] }
}).content as string
expect(content).toContain(
'If the user supplies a fully qualified `f/<folder>/...` path, use that exact path'
)
expect(content).toContain('Do not ask for folder confirmation')
expect(content).toContain('substitute a `u/admin/...` path unless a tool rejects it')
})
describe('folder guidance', () => {
const guidanceOf = (user: {
username: string
@@ -748,6 +748,7 @@ Path conventions:
- A workspace path starts with one of two namespaces; its trailing <name> may itself contain "/", so a path has three or more segments:
- \`u/${username}/<name>\` — your personal scope. Default for ad-hoc, exploratory, or scratch work.
- \`f/<folder>/<name>\` — a shared folder scope; the <folder> must already exist (a bare \`f/<name>\` with no folder segment is INVALID and will fail).
- If the user supplies a fully qualified \`f/<folder>/...\` path, use that exact path; they have already chosen the folder. Do not ask for folder confirmation or substitute a \`u/${username}/...\` path unless a tool rejects it.
- Default a bare name with no namespace prefix (e.g. "create a flow called myflow") to \`u/${username}/<name>\`. Never invent an \`f/<folder>/...\` path for a folder that does not exist.${folderGuidanceBlock}
Rules:
@@ -11,8 +11,8 @@
* When the mode is ready to ship to everyone, replace every call to
* `isGlobalAiEnabled()` with `true` and delete this file. The references are
* intentionally narrow (chat mode visibility, custom prompt settings, the
* `change_mode` tool enum, and the `/global_drafts` dev route) so the rip-out
* is a small grep.
* `change_mode` tool enum, the AI skills workspace settings tab, and the
* `/global_drafts` dev route) so the rip-out is a small grep.
*/
const STORAGE_KEY = 'wm_dev_global_ai'
@@ -129,9 +129,17 @@ export class AIChatEditorHandler {
const deletedChange = group.changes[0]
const addedChange = group.changes[1]
if (deletedChange.type === 'deleted' && addedChange.type === 'added_block') {
applyChange(this.editor, deletedChange)
addedChange.position.afterLineNumber = deletedChange.range.startLine - 1
applyChange(this.editor, addedChange)
this.editor.executeEdits('chat', [
{
range: {
startLineNumber: deletedChange.range.startLine,
startColumn: 1,
endLineNumber: deletedChange.range.endLine + 1,
endColumn: 0
},
text: addedChange.value + '\n'
}
])
} else {
throw new Error('Invalid group')
}
@@ -284,7 +292,7 @@ export class AIChatEditorHandler {
})
if (!opts?.applyAll) {
;({ collection, ids } = await displayVisualChanges(
; ({ collection, ids } = await displayVisualChanges(
'editor-windmill-chat-style',
this.editor,
changes,
@@ -17,6 +17,7 @@
import { AIMode } from '../AIChatManager.svelte'
import { getAiChatManager } from '../aiChatManagerContext'
import { Check, Play } from 'lucide-svelte'
import MermaidDisplay from './MermaidDisplay.svelte'
const aiChatManager = getAiChatManager()
@@ -90,7 +91,7 @@
if (
aiChatManager.mode !== AIMode.SCRIPT ||
!aiChatManager.scriptEditorApplyCode ||
code === aiChatManager.scriptEditorOptions?.code
code === aiChatManager.scriptEditorOptions?.getCode()
) {
return false
}
@@ -108,14 +109,18 @@
<div
class="relative w-full border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden"
>
<HighlightCode
className="p-1"
code={code ?? ''}
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language as string)]}
language={undefined}
onApplyCode={handleApplyCode}
{showApplyButton}
applyButtonIcon={aiChatManager.pendingNewCode ? Check : Play}
/>
{#if language === 'mermaid'}
<MermaidDisplay code={code ?? ''} />
{:else}
<HighlightCode
className="p-1"
code={code ?? ''}
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language as string)]}
language={undefined}
onApplyCode={handleApplyCode}
{showApplyButton}
applyButtonIcon={aiChatManager.pendingNewCode ? Check : Play}
/>
{/if}
</div>
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import { randomUUID } from '$lib/utils/uuid'
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
let { code }: { code: string } = $props()
const isDarkMode = useIsDarkMode()
let svg = $state<string | undefined>(undefined)
// The exact source that produced `svg`. The diagram is only shown while this
// still matches the current `code`, so a later edit that fails to parse falls
// back to the raw source instead of leaving a stale, mismatched diagram.
let renderedCode = $state<string | undefined>(undefined)
// Monotonic token so an earlier-started render that resolves late can't
// overwrite the result of a newer one (out-of-order async on rapid code/theme changes).
let renderSeq = 0
async function render(source: string, dark: boolean) {
const seq = ++renderSeq
if (!source?.trim()) {
svg = undefined
renderedCode = undefined
return
}
try {
const mermaid = (await import('mermaid')).default
mermaid.initialize({
startOnLoad: false,
theme: dark ? 'dark' : 'default',
securityLevel: 'strict',
// Throw on parse errors instead of injecting an orphan error diagram into the DOM.
suppressErrorRendering: true
})
// mermaid.render needs a fresh element id per attempt to avoid id collisions.
const result = await mermaid.render(`mermaid-${randomUUID()}`, source)
if (seq !== renderSeq) return
svg = result.svg
renderedCode = source
} catch {
// Parse failure (often a partial block still streaming in): fall back to the
// raw source. `showSvg` already hides any previous diagram since `renderedCode`
// no longer matches the current `code`.
}
}
$effect(() => {
void render(code, isDarkMode.val)
})
// Only show the diagram while it corresponds to the current source.
let showSvg = $derived(svg !== undefined && renderedCode === code)
</script>
{#if showSvg}
<div class="p-2 flex justify-center overflow-x-auto">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html svg}
</div>
{:else}
<!-- Fallback while loading or when rendering fails: show the raw source -->
<pre class="overflow-auto max-h-screen text-xs p-2">{code}</pre>
{/if}
@@ -867,7 +867,6 @@
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
syncExternalCode
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
@@ -931,7 +930,6 @@
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
syncExternalCode
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { OpenFlow } from '$lib/gen'
import type { FlowState } from './flowState'
import { nextId } from './flowModuleNextId'
function flowWith(ids: string[]): OpenFlow {
return {
summary: '',
value: {
modules: ids.map((id) => ({ id, value: { type: 'identity' } as any }))
}
} as OpenFlow
}
function stateWith(keys: string[]): FlowState {
return Object.fromEntries(keys.map((k) => [k, {}])) as FlowState
}
describe('nextId', () => {
it('produces a, b, c, ... for a fresh flow', () => {
expect(nextId(stateWith(['failure']), flowWith([]))).toBe('a')
expect(nextId(stateWith(['a', 'failure']), flowWith(['a']))).toBe('b')
expect(nextId(stateWith(['a', 'b', 'c', 'failure']), flowWith(['a', 'b', 'c']))).toBe('d')
})
it('ignores the reserved failure/preprocessor keys always present in flowState', () => {
expect(nextId(stateWith(['failure', 'preprocessor']), flowWith([]))).toBe('a')
})
// Regression: copy ids ("z2"), subflow result keys and other non-canonical keys land in
// flowState; charsToNumber on them used to leak into the max and made new steps jump to
// garbage ids like "bzw".
it('is not poisoned by copy ids', () => {
const ids = ['a', 'b', 'c']
const state = stateWith([...ids, 'c2', 'a2', 'z2', 'c10', 'failure'])
expect(nextId(state, flowWith(ids))).toBe('d')
})
it('is not poisoned by subflow result keys', () => {
const ids = ['a', 'b']
const state = stateWith([...ids, 'subflow:abcd', 'Result', 'failure'])
expect(nextId(state, flowWith(ids))).toBe('c')
})
// A step renamed to a long lowercase word ("process") is a valid base-26 string and would
// otherwise inflate the max; the length cutoff keeps such renames out of the sequence.
it('is not poisoned by renames to long lowercase words or underscored ids', () => {
const ids = ['a', 'b']
const state = stateWith([...ids, 'process', 'my_step', 'failure'])
expect(nextId(state, flowWith(ids))).toBe('c')
})
})
@@ -1,19 +1,35 @@
import type { OpenFlow } from '$lib/gen'
import { dfs } from './dfs'
import type { FlowState } from './flowState'
import { charsToNumber, numberToChars } from './idUtils'
import { charsToNumber, forbiddenIds, numberToChars } from './idUtils'
const reservedIds = new Set(forbiddenIds)
// Returns the base-26 value of a key only if it is a short, auto-generated step id
// (a, b, ..., z, aa, ...). flowState/module-id keys also include copy ids ("a2"), subflow
// result keys ("subflow:..."), reserved keys and user-renamed ids; feeding those through
// charsToNumber yields meaningless (often huge) numbers that would poison id generation and
// make new steps jump to ids like "bzw". Short non-canonical keys are rejected via a
// round-trip check; longer keys are skipped entirely, which also leaves user renames to long
// lowercase words (e.g. "process") out of the sequence.
function autoIdNumber(key: string): number | undefined {
if (key.length >= 4 || reservedIds.has(key)) {
return undefined
}
const num = charsToNumber(key)
if (num < 0 || numberToChars(num) !== key) {
return undefined
}
return num
}
// Computes the next available id
export function nextId(flowState: FlowState, fullFlow: OpenFlow): string {
const allIds = dfs(fullFlow.value.modules, (fm) => fm.id)
const max = allIds.concat(Object.keys(flowState)).reduce((acc, key) => {
if (key.length >= 4) {
return acc
} else {
const num = charsToNumber(key)
return Math.max(acc, num + 1)
}
const num = autoIdNumber(key)
return num === undefined ? acc : Math.max(acc, num + 1)
}, 0)
return numberToChars(max)
}
@@ -9,7 +9,11 @@
import { Database, Square } from 'lucide-svelte'
import FlowGraphPreviewButton from './FlowGraphPreviewButton.svelte'
import type { Job } from '$lib/gen'
import { getNodeColorClasses, aiActionToNodeState } from '$lib/components/graph'
import {
getNodeColorClasses,
aiActionToNodeState,
type FlowNodeState
} from '$lib/components/graph'
import { getGraphContext } from '$lib/components/graph/graphContext'
interface Props {
@@ -39,6 +43,9 @@
job?: Job
showJobStatus?: boolean
flowHasChanged?: boolean
/** When set, overrides the node outline with this run-state's colored outline.
* Used to mark the branch taken at runtime on branchone/branchall nodes. */
borderState?: FlowNodeState
}
let {
@@ -67,14 +74,13 @@
individualStepTests = false,
job,
showJobStatus = false,
flowHasChanged = false
flowHasChanged = false,
borderState = undefined
}: Props = $props()
const flowGraphContext = getGraphContext()
let isMultiSelected = $derived(
(flowGraphContext?.selectionManager?.selectedIds?.length ?? 0) > 1
)
let isMultiSelected = $derived((flowGraphContext?.selectionManager?.selectedIds?.length ?? 0) > 1)
const outputPickerVisible = $derived(
(nodeKind || (inputJson && Object.keys(inputJson).length > 0)) && editMode
@@ -96,6 +102,10 @@
// AI action colors take priority over execution state, fallback to _VirtualItem
const effectiveState = $derived(aiActionToNodeState(action) ?? outputType ?? '_VirtualItem')
let colorClasses = $derived(getNodeColorClasses(effectiveState, selected))
// The branch taken at runtime keeps its outline regardless of selection so it stays visible.
let outlineClasses = $derived(
borderState ? getNodeColorClasses(borderState, true).outline : colorClasses.outline
)
</script>
<VirtualItemWrapper
@@ -109,7 +119,7 @@
{#snippet children({ hover })}
<div class="flex flex-col w-full">
<div
class="flex flex-row justify-between {colorClasses.outline} {center
class="flex flex-row justify-between {outlineClasses} {center
? 'items-center'
: 'items-baseline'} w-full overflow-hidden rounded-md p-2 text-2xs module text-primary"
>
@@ -6,6 +6,7 @@
import { X } from 'lucide-svelte'
import type { BranchAllStartN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
import { computeBorderStatus } from '../utils'
interface Props {
data: BranchAllStartN['data']
id: string
@@ -14,6 +15,10 @@
let { data, id }: Props = $props()
const { selectionManager } = getGraphContext()
let borderStatus = $derived(
computeBorderStatus(data.branchIndex, 'branchall', data.flowModuleState)
)
</script>
<NodeWrapper nodeId={id}>
@@ -22,6 +27,7 @@
label={data.label}
selectable
selected={selectionManager && selectionManager.isNodeSelected(id)}
borderState={borderStatus}
on:select={() => {
setTimeout(() => data.eventHandlers.select(data.id))
}}
@@ -6,6 +6,7 @@
import { X } from 'lucide-svelte'
import type { BranchOneStartN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
import { computeBorderStatus } from '../utils'
interface Props {
data: BranchOneStartN['data']
id: string
@@ -13,6 +14,12 @@
const { selectionManager } = getGraphContext()
let { data, id }: Props = $props()
// branchIndex is -1 for the default branch and 0-based for explicit branches;
// branchChosen is 0 for default and 1-based, hence the +1.
let borderStatus = $derived(
computeBorderStatus(data.branchIndex + 1, 'branchone', data.flowModuleState)
)
</script>
<NodeWrapper nodeId={id}>
@@ -22,6 +29,7 @@
preLabel={data.preLabel}
selectable
selected={selectionManager && selectionManager.isNodeSelected(id)}
borderState={borderStatus}
on:select={() => {
setTimeout(() => data?.eventHandlers?.select(data.id))
}}
@@ -19,7 +19,8 @@ export function computeBorderStatus(
} else {
let flow_jobs_success = graphModuleState?.flow_jobs_success
if (!flow_jobs_success) {
return 'WaitingForPriorSteps'
// No run yet: leave the branch border neutral instead of forcing a highlight.
return undefined
} else {
let status = flow_jobs_success?.[branchIndex]
if (status == undefined) {

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