From 1fd729ac1aaec1e6a3af0d6db5fc1d6e1db6fa62 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 11:23:30 +0200 Subject: [PATCH 01/51] feat: add an instance-wide accent color setting with sidebar tint (#11335) * feat: add an instance-wide accent color setting with sidebar tint Co-Authored-By: Claude Opus 5.5 (1M context) * fix: paint the cached accent before the license resolves Co-Authored-By: Claude Opus 5.5 (1M context) * feat: serve the banner and accent color from one cached endpoint Co-Authored-By: Claude Opus 5.5 (1M context) * fix: invalidate the instance ui cache and bound it with a ttl Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the instance ui ttl under the client poll period Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: load the banner and accent color once per page load Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: read the banner and accent color without a server cache Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show a cleared accent color as off Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/tests/instance_config.rs | 31 ++++ backend/windmill-api-settings/src/lib.rs | 24 ++- backend/windmill-api/openapi.yaml | 20 +++ .../windmill-common/src/global_settings.rs | 65 +++++++++ .../windmill-common/src/instance_config.rs | 8 + frontend/src/lib/accentColor.test.ts | 25 ++++ frontend/src/lib/accentColor.ts | 138 ++++++++++++++++++ .../src/lib/components/InstanceBanner.svelte | 52 +------ .../src/lib/components/InstanceSetting.svelte | 3 + .../src/lib/components/InstanceUiSync.svelte | 55 +++++++ frontend/src/lib/components/instanceBanner.ts | 6 - .../src/lib/components/instanceSettings.ts | 18 +++ .../AccentColorSetting.svelte | 51 +++++++ .../lib/components/sidebar/sidebarChrome.ts | 6 +- frontend/src/lib/instanceUi.ts | 9 ++ .../src/routes/(root)/(logged)/+layout.svelte | 11 +- frontend/tailwind.config.cjs | 11 +- 17 files changed, 467 insertions(+), 66 deletions(-) create mode 100644 frontend/src/lib/accentColor.test.ts create mode 100644 frontend/src/lib/accentColor.ts create mode 100644 frontend/src/lib/components/InstanceUiSync.svelte create mode 100644 frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte create mode 100644 frontend/src/lib/instanceUi.ts diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 5ab870a364..26a46d272e 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1608,3 +1608,34 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool

) { + clear_settings_and_configs(&db).await; + + let mut desired = BTreeMap::new(); + desired.insert( + "accent_color".to_string(), + serde_json::json!("#000;}body{display:none"), + ); + let err = windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect_err("a non-hex accent color must fail the sync"); + assert!(err.to_string().contains("accent_color"), "got: {err}"); + assert!(get_global_setting(&db, "accent_color").await.is_none()); + + desired.insert("accent_color".to_string(), serde_json::json!("#1f9d55")); + windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect("a hex accent color must sync"); +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index e8d8a7124c..bc52d85d54 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -58,9 +58,9 @@ use windmill_common::{ error::{self, pg_error_message, JsonResult, Result}, get_database_url, global_settings::{ - AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, - CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, - DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, + ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, + AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, + DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES, @@ -115,6 +115,17 @@ async fn get_ruff_config_unauthed(Extension(db): Extension) -> error::Result .unwrap()) } +/// The announcement banner and accent color, which every signed-in session reads on each +/// full page load. +async fn get_instance_ui( + Extension(db): Extension, + _authed: ApiAuthed, +) -> JsonResult { + Ok(Json( + windmill_common::global_settings::get_instance_ui(&db).await?, + )) +} + pub fn global_service() -> Router { #[warn(unused_mut)] let r = Router::new() @@ -126,6 +137,7 @@ pub fn global_service() -> Router { "/global/{key}", post(set_global_setting).get(get_global_setting), ) + .route("/instance_ui", get(get_instance_ui)) .route("/list_global", get(list_global_settings)) .route("/github_app_stale_webhooks", get(github_app_stale_webhooks)) .route( @@ -1216,6 +1228,12 @@ async fn run_setting_pre_write_hook( } } } + ACCENT_COLOR_SETTING => match value { + serde_json::Value::Null => {} + serde_json::Value::String(s) if s.trim().is_empty() => {} + v => windmill_common::global_settings::validate_accent_color(v) + .map_err(|e| error::Error::BadRequest(format!("{ACCENT_COLOR_SETTING}: {e}")))?, + }, _ => {} } Ok(()) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0dd9f8620e..016342a6fe 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1762,6 +1762,26 @@ paths: schema: type: string + /settings/instance_ui: + get: + summary: get the instance-wide announcement banner and accent color + description: | + Readable by any authenticated user; every page load reads it. Each value + is the raw stored setting, or null when unset. + operationId: getInstanceUi + tags: + - setting + responses: + "200": + description: the instance_banner and accent_color settings + content: + application/json: + schema: + type: object + properties: + instance_banner: {} + accent_color: {} + /settings_u/ruff_config: get: summary: get instance ruff config (unauthenticated) diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 6b9aae519d..e72a2bb4e2 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -281,6 +281,51 @@ pub fn validate_instance_banner(value: &serde_json::Value) -> Result<(), String> Ok(()) } +/// Instance-wide accent color (`#rrggbb`) that recolors the UI's accent tokens and +/// tints the sidebar, so each environment of a deployment is recognizable at a glance. +/// Readable by any authenticated user, like [`INSTANCE_BANNER_SETTING`]. +pub const ACCENT_COLOR_SETTING: &str = "accent_color"; + +/// Validate an [`ACCENT_COLOR_SETTING`] value. +/// +/// Only `#rrggbb` is accepted: the value is interpolated into a stylesheet every user +/// loads, so anything looser is CSS injection into every session of the instance. +pub fn validate_accent_color(value: &serde_json::Value) -> Result<(), String> { + let s = value + .as_str() + .ok_or_else(|| "must be a string".to_string())?; + let hex = s.strip_prefix('#').unwrap_or(""); + if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("must be a hex color of the form #rrggbb".to_string()); + } + Ok(()) +} + +/// The settings every signed-in browser reads on each full page load. +#[derive(serde::Serialize, Clone, Debug, Default, PartialEq)] +pub struct InstanceUi { + pub instance_banner: Option, + pub accent_color: Option, +} + +pub async fn get_instance_ui(db: &Pool) -> error::Result { + let rows = sqlx::query!( + "SELECT name, value FROM global_settings WHERE name = ANY($1)", + &[INSTANCE_BANNER_SETTING, ACCENT_COLOR_SETTING] as &[&str] + ) + .fetch_all(db) + .await?; + let mut ui = InstanceUi::default(); + for row in rows { + match row.name.as_str() { + INSTANCE_BANNER_SETTING => ui.instance_banner = Some(row.value), + ACCENT_COLOR_SETTING => ui.accent_color = Some(row.value), + _ => {} + } + } + Ok(ui) +} + /// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] value. /// /// The receiver path is appended to it verbatim, so anything that doesn't @@ -939,6 +984,26 @@ mod tests { } } + #[test] + fn accent_color_accepts_only_hex_rgb() { + for ok in ["#1f9d55", "#ABCDEF"] { + assert!( + validate_accent_color(&serde_json::json!(ok)).is_ok(), + "{ok}" + ); + } + for bad in [ + serde_json::json!("1f9d55"), + serde_json::json!("#fff"), + serde_json::json!("red"), + serde_json::json!("#000000;} body{display:none"), + serde_json::json!("#12345g"), + serde_json::json!(123), + ] { + assert!(validate_accent_color(&bad).is_err(), "{bad}"); + } + } + #[test] fn instance_banner_rejects_unsafe_and_malformed_values() { // The link becomes the href of an anchor shown to every user of the instance, diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index f4884d736e..508379c3a4 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -1354,6 +1354,14 @@ pub async fn sync_global_settings_declarative( .map_err(|e| anyhow::anyhow!("{banner_key}: {e}"))?, } + let accent_key = crate::global_settings::ACCENT_COLOR_SETTING; + match desired.get(accent_key) { + None | Some(serde_json::Value::Null) => {} + Some(serde_json::Value::String(s)) if s.trim().is_empty() => {} + Some(color) => crate::global_settings::validate_accent_color(color) + .map_err(|e| anyhow::anyhow!("{accent_key}: {e}"))?, + } + // An origin list that cannot be parsed is dropped at boot, leaving the // empty default — which is no restriction at all. Rejecting it here is what // keeps a typo in a ConfigMap from silently widening CORS instance-wide. diff --git a/frontend/src/lib/accentColor.test.ts b/frontend/src/lib/accentColor.test.ts new file mode 100644 index 0000000000..6d3b855240 --- /dev/null +++ b/frontend/src/lib/accentColor.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { accentStylesheet } from './accentColor' + +describe('accentStylesheet', () => { + it('refuses anything but #rrggbb, since the value lands in a stylesheet', () => { + for (const bad of ['#000;}body{display:none', '#fff', 'red', '1f9d55', 123, null]) { + expect(accentStylesheet(bad)).toBeUndefined() + } + }) + + it('overrides every accent token with an in-range r g b triplet in each theme', () => { + // A saturated color exercises the gamut clamp, a grey the zero-chroma path. + for (const color of ['#ff0000', '#808080']) { + const rules = accentStylesheet(color)!.split('\n') + expect(rules).toHaveLength(3) + for (const rule of rules) { + const triplets = [...rule.matchAll(/--color-[a-z-]+: (\d+) (\d+) (\d+);/g)] + expect(triplets).toHaveLength(10) + for (const [, ...channels] of triplets) { + for (const c of channels) expect(Number(c)).toBeLessThanOrEqual(255) + } + } + } + }) +}) diff --git a/frontend/src/lib/accentColor.ts b/frontend/src/lib/accentColor.ts new file mode 100644 index 0000000000..f11a96bb79 --- /dev/null +++ b/frontend/src/lib/accentColor.ts @@ -0,0 +1,138 @@ +import tokensFile from './assets/tokens/tokens.json' +import githubDarkTokens from './assets/tokens/githubDark.json' +import { darkModeName, lightModeName } from './assets/tokens/colorTokensConfig' + +/** Instance-wide accent color, `#rrggbb` (mirrors `ACCENT_COLOR_SETTING` in the backend). */ +export const ACCENT_COLOR_SETTING = 'accent_color' + +const ACCENT_TOKENS = [ + 'surface-accent-primary', + 'surface-accent-hover', + 'surface-accent-clicked', + 'surface-accent-selected', + 'surface-accent-secondary', + 'surface-accent-secondary-hover', + 'surface-accent-secondary-clicked', + 'text-accent', + 'border-accent', + 'border-selected' +] as const + +const STYLE_ID = 'wm-accent-color' +const ACCENT_ATTR = 'data-wm-accent' + +/** + * The stored setting as a color, or `undefined` for "use the default theme". + * + * The backend already rejects anything but `#rrggbb`, but the value is interpolated into a + * stylesheet, so this is the last place that can refuse a row written around the validator. + */ +export function parseAccentColor(value: unknown): string | undefined { + return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) + ? value.toLowerCase() + : undefined +} + +type Oklch = [l: number, c: number, h: number] + +function srgbToLinear(v: number): number { + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) +} + +function linearToSrgb(v: number): number { + return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055 +} + +function hexToOklch(hex: string): Oklch { + const [r, g, b] = [1, 3, 5].map((i) => srgbToLinear(parseInt(hex.slice(i, i + 2), 16) / 255)) + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s + const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + return [L, Math.hypot(A, B), Math.atan2(B, A)] +} + +/** Linear sRGB channels, possibly out of [0, 1] when the color is outside the gamut. */ +function oklchToLinearRgb([L, C, h]: Oklch): [number, number, number] { + const A = C * Math.cos(h) + const B = C * Math.sin(h) + const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3 + const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3 + const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3 + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s + ] +} + +/** `r g b` triplet (the format the `--color-*` variables use), chroma reduced into gamut. */ +function oklchToRgbTriplet([L, C, h]: Oklch): string { + const inGamut = (c: number) => + oklchToLinearRgb([L, c, h]).every((v) => v >= -1e-4 && v <= 1 + 1e-4) + let lo = 0 + let hi = C + if (!inGamut(C)) { + for (let i = 0; i < 20; i++) { + const mid = (lo + hi) / 2 + if (inGamut(mid)) lo = mid + else hi = mid + } + } else lo = C + return oklchToLinearRgb([L, lo, h]) + .map((v) => Math.round(Math.min(1, Math.max(0, linearToSrgb(v))) * 255)) + .join(' ') +} + +// Below this chroma the chosen color reads as grey, and the accents fade with it. +const FULL_CHROMA = 0.1 + +/** + * Move each accent token to the chosen hue while keeping its own lightness, so every + * text/background pairing keeps the contrast the default palette was designed with. + * Taking the chosen color verbatim would put white text on a pale yellow button. + */ +function recolor(tokens: Record, accent: Oklch): string { + const chromaScale = Math.min(1, accent[1] / FULL_CHROMA) + return ACCENT_TOKENS.map((name) => { + const [l, c] = hexToOklch(tokens[name]) + return `--color-${name}: ${oklchToRgbTriplet([l, c * chromaScale, accent[2]])};` + }).join('') +} + +/** The accent overrides for every theme, or `undefined` when `value` is not `#rrggbb`. */ +export function accentStylesheet(value: unknown): string | undefined { + const color = parseAccentColor(value) + if (color == undefined) return undefined + const accent = hexToOklch(color) + // `:root[attr]` outranks the `html`, `html.dark` and `html.dark.github-dark` rules + // that define the default tokens, whatever order the stylesheets load in. + return [ + `:root[${ACCENT_ATTR}]{${recolor(tokensFile.tokens[lightModeName], accent)}` + + `--sidebar-bg-light:color-mix(in oklab, ${color} 18%, var(--sidebar-bg-light-base));}`, + `:root[${ACCENT_ATTR}].dark{${recolor(tokensFile.tokens[darkModeName], accent)}` + + `--sidebar-bg-dark:color-mix(in oklab, ${color} 22%, var(--sidebar-bg-dark-base));}`, + `:root[${ACCENT_ATTR}].dark.github-dark{${recolor(githubDarkTokens, accent)}}` + ].join('\n') +} + +/** Apply the instance accent to the whole document, or restore the default with `undefined`. */ +export function applyAccentColor(value: string | undefined): void { + const root = document.documentElement + let style = document.getElementById(STYLE_ID) + const css = accentStylesheet(value) + if (css == undefined) { + style?.remove() + root.removeAttribute(ACCENT_ATTR) + return + } + if (!style) { + style = document.createElement('style') + style.id = STYLE_ID + document.head.appendChild(style) + } + style.textContent = css + root.setAttribute(ACCENT_ATTR, '') +} diff --git a/frontend/src/lib/components/InstanceBanner.svelte b/frontend/src/lib/components/InstanceBanner.svelte index 9e47f9399a..0277cba4c1 100644 --- a/frontend/src/lib/components/InstanceBanner.svelte +++ b/frontend/src/lib/components/InstanceBanner.svelte @@ -3,60 +3,16 @@ import { classes as alertClasses, icons as alertIcons } from '$lib/components/common/alert/model' import { ExternalLink, X } from 'lucide-svelte' import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' - import { - fetchInstanceBanner, - isInstanceBannerVisible, - type ResolvedInstanceBanner - } from './instanceBanner' - import { instanceSettingsSaved } from './instanceSettings' + import { isInstanceBannerVisible, resolveInstanceBanner } from './instanceBanner' + import { instanceUi } from '$lib/instanceUi' - // Mounted only on EE (see the render site in the logged layout), so the poll costs - // nothing on CE. An announcement is only worth broadcasting while it is current, hence - // polling rather than waiting for the next full page load: a session left open all day - // is exactly the one that needs to hear about the maintenance window. - const POLL_MS = 60_000 - - let banner = $state(undefined) + // Fetched by `InstanceUiSync`, which also carries the accent color. + let banner = $derived(resolveInstanceBanner($instanceUi?.instance_banner)) // Per-viewer, per-announcement. Holds the fingerprint of the dismissed banner, so // a new announcement shows up again for everyone who dismissed the previous one. const dismissed = useLocalStorageValue('instance_banner_dismissed', '', 'string') - // The poll, the tab-focus refresh and the post-save refresh all call `load()` and can - // overlap. Responses are not ordered, so without this a slow earlier fetch lands last - // and puts a retracted announcement back on screen until the next successful poll. - let latestLoad = 0 - - async function load() { - const generation = ++latestLoad - try { - const next = await fetchInstanceBanner() - if (generation === latestLoad) banner = next - } catch (e) { - // Keep whatever is on screen: a transient failure must not silently retract - // an announcement that is still in force. - console.warn('Could not fetch the instance banner', e) - } - } - - $effect(() => { - // Re-runs on save so a superadmin who just published an announcement sees it - // immediately rather than on the next poll or page load. - $instanceSettingsSaved - load() - const interval = setInterval(() => { - if (!document.hidden) load() - }, POLL_MS) - const onVisible = () => { - if (!document.hidden) load() - } - document.addEventListener('visibilitychange', onVisible) - return () => { - clearInterval(interval) - document.removeEventListener('visibilitychange', onVisible) - } - }) - let shown = $derived(isInstanceBannerVisible(banner, dismissed.val)) let palette = $derived(alertClasses[banner?.severity ?? 'info']) let Icon = $derived(alertIcons[banner?.severity ?? 'info']) diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index aedd9cdf6b..0da63ffbb0 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -27,6 +27,7 @@ import WebhookBaseUrlSetting from './instanceSettings/WebhookBaseUrlSetting.svelte' import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte' import InstanceBannerSetting from './instanceSettings/InstanceBannerSetting.svelte' + import AccentColorSetting from './instanceSettings/AccentColorSetting.svelte' import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte' import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte' import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte' @@ -872,6 +873,8 @@ {:else if setting.fieldType == 'instance_banner'} + {:else if setting.fieldType == 'accent_color'} + {/if} {#if hasError} diff --git a/frontend/src/lib/components/InstanceUiSync.svelte b/frontend/src/lib/components/InstanceUiSync.svelte new file mode 100644 index 0000000000..c9ae00eb29 --- /dev/null +++ b/frontend/src/lib/components/InstanceUiSync.svelte @@ -0,0 +1,55 @@ + diff --git a/frontend/src/lib/components/instanceBanner.ts b/frontend/src/lib/components/instanceBanner.ts index f1693024b8..4cceb7ad59 100644 --- a/frontend/src/lib/components/instanceBanner.ts +++ b/frontend/src/lib/components/instanceBanner.ts @@ -1,5 +1,3 @@ -import { SettingService } from '$lib/gen' - /** Drives the banner palette and icon; the subset of `AlertType` that fits an announcement. */ export type InstanceBannerSeverity = 'info' | 'warning' | 'error' @@ -123,7 +121,3 @@ export function isInstanceBannerVisible( if (banner == undefined) return false return !banner.dismissible || dismissedFingerprint !== banner.fingerprint } - -export async function fetchInstanceBanner(): Promise { - return resolveInstanceBanner(await SettingService.getGlobal({ key: INSTANCE_BANNER_SETTING })) -} diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 1fe45931a2..92988f53b5 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -2,6 +2,7 @@ import type { ButtonType } from './common/button/model' import { allowedOriginsSettingError } from './triggers/http/utils' import { z } from 'zod' import { instanceBannerFormError } from './instanceBanner' +import { ACCENT_COLOR_SETTING, parseAccentColor } from '$lib/accentColor' import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration' import { writable } from 'svelte/store' @@ -72,6 +73,7 @@ export interface Setting { | 'ws_connectivity' | 'retention_overrides' | 'instance_banner' + | 'accent_color' storage: SettingStorage advancedToggle?: { label: string @@ -334,6 +336,22 @@ export const settings: Record = { hideInQuickSetup: true, // Gates Save. The card renders the specific message itself, so no `error` here. isValid: (value: any) => instanceBannerFormError(value) == undefined + }, + { + label: 'Accent color', + description: + 'Recolors buttons, selections and the sidebar for every user, so each environment (e.g. staging vs. production) is recognizable at a glance.', + key: ACCENT_COLOR_SETTING, + fieldType: 'accent_color', + storage: 'setting', + ee_only: '', + hideInQuickSetup: true, + error: 'Must be a hex color like #1f9d55', + isValid: (value: unknown) => + value === undefined || + value === null || + value === '' || + parseAccentColor(value) !== undefined } ], Jobs: [ diff --git a/frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte b/frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte new file mode 100644 index 0000000000..619d32a7a0 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte @@ -0,0 +1,51 @@ + + +

+ enabled, (v) => ($values[ACCENT_COLOR_SETTING] = v ? DEFAULT_PICK : undefined) + } + options={{ right: 'Use a custom accent color' }} + /> + {#if enabled} +
+ ($values[ACCENT_COLOR_SETTING] = e.currentTarget.value)} + /> +
+ raw ?? '', (v) => ($values[ACCENT_COLOR_SETTING] = String(v).trim() || undefined)} + /> +
+
+ + Accents keep the default palette's lightness and take this color's hue, so text stays readable + whatever color you pick. Applies to every user after saving. + + {/if} +
diff --git a/frontend/src/lib/components/sidebar/sidebarChrome.ts b/frontend/src/lib/components/sidebar/sidebarChrome.ts index 71ff703d27..4c58202416 100644 --- a/frontend/src/lib/components/sidebar/sidebarChrome.ts +++ b/frontend/src/lib/components/sidebar/sidebarChrome.ts @@ -1,6 +1,6 @@ // Sidebar rail background. Kept here as the single source for every host that // renders or mimics the sidebar (app layout, kitchen-sink harness). -// The dark value is a theme-driven CSS variable so it adapts across dark -// variants (default vs GitHub dark) — see tailwind.config.cjs `--sidebar-bg-dark`. -export const SIDEBAR_BG = '#F3F3F7' +// Both are CSS variables (see tailwind.config.cjs `--sidebar-bg-*`) so they adapt +// across dark variants and to the instance accent tint (accentColor.ts). +export const SIDEBAR_BG = 'var(--sidebar-bg-light)' export const SIDEBAR_BG_DARK = 'var(--sidebar-bg-dark)' diff --git a/frontend/src/lib/instanceUi.ts b/frontend/src/lib/instanceUi.ts new file mode 100644 index 0000000000..b9b87d139c --- /dev/null +++ b/frontend/src/lib/instanceUi.ts @@ -0,0 +1,9 @@ +import { writable } from 'svelte/store' +import type { GetInstanceUiResponse } from '$lib/gen' + +/** + * The last `/settings/instance_ui` response: the announcement banner and accent color. + * `undefined` until one lands, and left as is when a fetch fails, so a transient error + * never retracts an announcement still in force. Written only by `InstanceUiSync`. + */ +export const instanceUi = writable(undefined) diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index e9d4493e66..25d2fd156f 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -81,6 +81,7 @@ import { pruneMeaninglessDrafts } from '$lib/userDraftPrune' import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte' import InstanceBanner from '$lib/components/InstanceBanner.svelte' + import InstanceUiSync from '$lib/components/InstanceUiSync.svelte' import { onDestroy, setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -1437,11 +1438,13 @@ {/if}
+ + {#if $enterpriseLicense && !menuHidden} - + {/if} {#if $userStore?.is_service_account} diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index a5aea63715..5c623fbadc 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -603,6 +603,12 @@ const config = { backgroundColor: 'rgb(var(--color-surface-primary))', color: 'rgb(var(--color-text-primary))', + // Sidebar rail chrome — consumed via var(--sidebar-bg-light) (see sidebarChrome.ts). + // The `-base` vars hold the theme's color; the accent (accentColor.ts) tints + // `--sidebar-bg-*` from them, which a self-reference could not do. + '--sidebar-bg-light-base': '#F3F3F7', + '--sidebar-bg-light': 'var(--sidebar-bg-light-base)', + '--vscode-editorSuggestWidget-background': '#f3f3f3', '--vscode-editorHoverWidget-foreground': '#616161', '--vscode-editorHoverWidget-border': '#c8c8c8', @@ -622,7 +628,8 @@ const config = { ...darkColorVariables, // Sidebar rail chrome — consumed via var(--sidebar-bg-dark) (see sidebarChrome.ts). - '--sidebar-bg-dark': '#1e232e', + '--sidebar-bg-dark-base': '#1e232e', + '--sidebar-bg-dark': 'var(--sidebar-bg-dark-base)', '--vscode-editorSuggestWidget-background': '#252526', '--vscode-editorHoverWidget-foreground': '#cccccc', @@ -638,7 +645,7 @@ const config = { // GitHub's canvas.inset — the authentic recessed surface, sitting below // the canvas (#0d1117) like the sidebar-vs-primary relationship in the // default dark theme. - '--sidebar-bg-dark': '#010409', + '--sidebar-bg-dark-base': '#010409', // Monaco popup chrome — the &.dark defaults above are VS Code's greys; // override so the widgets match the GitHub Dark palette in this variant. From 0bac766756cf9ebcb615fe20d5a69c09c823c725 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 11:38:41 +0200 Subject: [PATCH 02/51] feat: refresh MCP tools when scripts and flows change (#11337) * feat: notify MCP clients when a workspace's scripts or flows change Advertise tools.listChanged and implement subscriptions/listen, so a 2026-07-28 client refreshes its tool list when scripts or flows are deployed, archived, renamed or deleted. Changes reach every replica via new statement-level notify_event triggers; MCP-originated changes also signal the serving replica inline. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: signal each workspace once per notify-event poll batch Co-Authored-By: Claude Opus 5.5 (1M context) * fix: signal script/flow path moves, keep unrelated updates trigger-free Row-level UPDATE OF archived/deleted/path triggers with a WHEN guard replace the statement-level ones, which built transition tables for every UPDATE on script and flow. Path moves from username changes and offboarding are now signalled. subscriptions/listen is refused when the client asks for nothing this server sends. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: signal path moves only for unarchived versions A username change or offboarding rewrites the path of every version, archived ones included, which would queue one notify_event per version. Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: detect MCP tool-list changes by polling a workspace fingerprint Replace the notify_event triggers and poller hook with a per-process, per-workspace poll of a hash over the live scripts and flows, run only while a subscriptions/listen stream watches that workspace. Every write path (UI, CLI, git sync, user renames, workspace moves) is covered with no migration; an MCP-originated change polls at once. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: announce the first fingerprint so a change before the baseline is not lost Also poll immediately after a script/flow mutation through a multi-workspace token, and pin the fingerprint test on a lock update, which is the unrelated write that actually happens. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: open every tool-list subscription with one notification A subscriber joining an existing watcher missed a change the watcher recorded before it subscribed. Also move the fingerprint query to a runnables module, since it spans scripts and flows. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-api/src/mcp/core.rs | 6 + backend/windmill-common/src/lib.rs | 1 + backend/windmill-common/src/runnables.rs | 22 +++ .../tests/runnable_list_fingerprint.rs | 54 +++++++ backend/windmill-mcp/src/server/backend.rs | 5 + .../windmill-mcp/src/server/list_changes.rs | 104 +++++++++++++ backend/windmill-mcp/src/server/mod.rs | 1 + backend/windmill-mcp/src/server/runner.rs | 142 ++++++++++++++++-- 8 files changed, 321 insertions(+), 14 deletions(-) create mode 100644 backend/windmill-common/src/runnables.rs create mode 100644 backend/windmill-common/tests/runnable_list_fingerprint.rs create mode 100644 backend/windmill-mcp/src/server/list_changes.rs diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 86fbec1806..472c7bbec9 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -386,6 +386,12 @@ impl McpBackend for WindmillBackend { }) } + async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult { + windmill_common::runnables::runnable_list_fingerprint(&self.db, workspace_id) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None)) + } + fn all_endpoint_tools(&self) -> Vec { all_tools() } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 3e74d8e17f..1015948882 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -118,6 +118,7 @@ pub mod queue; pub mod queue_metrics; pub mod result_stream; pub mod runnable_settings; +pub mod runnables; pub mod schedule; pub mod schema; pub mod scripts; diff --git a/backend/windmill-common/src/runnables.rs b/backend/windmill-common/src/runnables.rs new file mode 100644 index 0000000000..579e5e248e --- /dev/null +++ b/backend/windmill-common/src/runnables.rs @@ -0,0 +1,22 @@ +//! Queries spanning both scripts and flows. + +use crate::{error::Error, DB}; + +/// An opaque value that changes whenever a workspace's live scripts or flows are added, +/// removed, moved or redeployed: a redeploy is a new script hash or a new last flow +/// version, so schema and description changes register too. Not filtered by caller, +/// so it reveals nothing beyond whether it changed. +pub async fn runnable_list_fingerprint(db: &DB, workspace_id: &str) -> Result { + Ok(sqlx::query_scalar::<_, String>( + "SELECT COALESCE(md5(string_agg(k, ',' ORDER BY k)), '') FROM ( + SELECT 's' || path || ':' || hash FROM script + WHERE workspace_id = $1 AND archived = false + UNION ALL + SELECT 'f' || path || ':' || COALESCE(versions[array_upper(versions, 1)], 0) FROM flow + WHERE workspace_id = $1 AND archived = false + ) t(k)", + ) + .bind(workspace_id) + .fetch_one(db) + .await?) +} diff --git a/backend/windmill-common/tests/runnable_list_fingerprint.rs b/backend/windmill-common/tests/runnable_list_fingerprint.rs new file mode 100644 index 0000000000..d7438f0008 --- /dev/null +++ b/backend/windmill-common/tests/runnable_list_fingerprint.rs @@ -0,0 +1,54 @@ +use sqlx::{Pool, Postgres}; +use windmill_common::runnables::runnable_list_fingerprint; + +/// MCP clients refresh their tool list when this fingerprint moves, so it must move on every +/// change to what the listing shows and stay put on writes it does not show. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn fingerprint_tracks_the_live_runnable_list(db: Pool) { + let fp = || async { + runnable_list_fingerprint(&db, "test-workspace") + .await + .unwrap() + }; + let exec = |sql: &'static str| { + let db = db.clone(); + async move { sqlx::query(sql).execute(&db).await.unwrap() } + }; + + let mut last = fp().await; + let mut assert_moved = async |what: &str, moved: bool| { + let now = fp().await; + assert_eq!(now != last, moved, "{what}"); + last = now; + }; + + exec( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, kind) + VALUES ('test-workspace', 1, 'f/test/a', '', '', 'def main(): pass', 'test-user', 'python3', 'script')", + ) + .await; + assert_moved("deploy", true).await; + + exec("UPDATE script SET lock = '' WHERE hash = 1").await; + assert_moved("lock update", false).await; + + exec("UPDATE script SET path = 'f/test/b' WHERE hash = 1").await; + assert_moved("path move", true).await; + + exec("UPDATE script SET archived = true WHERE hash = 1").await; + assert_moved("archive", true).await; + + exec( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, versions) + VALUES ('test-workspace', 'f/test/flow', '', '', '{}'::jsonb, 'test-user', '{}'::json, ARRAY[1]::bigint[])", + ) + .await; + assert_moved("flow create", true).await; + + exec("UPDATE flow SET versions = array_append(versions, 2::bigint) WHERE path = 'f/test/flow'") + .await; + assert_moved("flow redeploy", true).await; + + exec("DELETE FROM flow WHERE path = 'f/test/flow'").await; + assert_moved("flow delete", true).await; +} diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 7ca1eb08a1..4801bfaa99 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -208,6 +208,11 @@ pub trait McpBackend: Send + Sync + Clone + 'static { workspace_id: &str, ) -> BackendResult; + /// An opaque value that changes whenever the workspace's live scripts or flows are + /// added, removed, moved or redeployed, compared across polls to detect tool-list + /// changes. + async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult; + // ───────────────────────────────────────────────────────────────── // Endpoint Tools // ───────────────────────────────────────────────────────────────── diff --git a/backend/windmill-mcp/src/server/list_changes.rs b/backend/windmill-mcp/src/server/list_changes.rs new file mode 100644 index 0000000000..8470cff630 --- /dev/null +++ b/backend/windmill-mcp/src/server/list_changes.rs @@ -0,0 +1,104 @@ +//! Detects changes to a workspace's scripts and flows for `subscriptions/listen`. +//! +//! Each process polls a fingerprint of every workspace one of its listeners watches, +//! so a change made on any replica, through any path (UI, CLI, git sync, a user +//! rename), reaches every listener within one poll interval with no cross-replica +//! signalling. + +use crate::server::backend::McpBackend; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::{broadcast, Notify}; + +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +struct Watcher { + changes: broadcast::Sender<()>, + poll_now: Arc, +} + +#[derive(Default)] +pub(crate) struct ListChanges { + watchers: Mutex>, +} + +impl ListChanges { + /// Subscribe to `workspace_id`'s changes, starting its poller if this is the + /// first listener for it in this process. + pub(crate) fn subscribe( + self: &Arc, + backend: &Arc, + workspace_id: &str, + ) -> broadcast::Receiver<()> { + let mut watchers = self.watchers.lock().unwrap(); + if let Some(watcher) = watchers.get(workspace_id) { + return watcher.changes.subscribe(); + } + let (changes, rx) = broadcast::channel(16); + let poll_now = Arc::new(Notify::new()); + watchers.insert( + workspace_id.to_string(), + Watcher { changes: changes.clone(), poll_now: poll_now.clone() }, + ); + tokio::spawn(self.clone().poll( + backend.clone(), + workspace_id.to_string(), + changes, + poll_now, + )); + rx + } + + /// Poll `workspace_id` now rather than at its next interval, after this process + /// changed it: the client that made the change is the one waiting on it. Polling + /// rather than signalling directly keeps the poller's baseline current, so the + /// same change is not reported again on the next tick. + pub(crate) fn poll_now(&self, workspace_id: &str) { + if let Some(watcher) = self.watchers.lock().unwrap().get(workspace_id) { + watcher.poll_now.notify_one(); + } + } + + async fn poll( + self: Arc, + backend: Arc, + workspace_id: String, + changes: broadcast::Sender<()>, + poll_now: Arc, + ) { + let mut interval = tokio::time::interval(POLL_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last = None; + loop { + tokio::select! { + _ = interval.tick() => {} + _ = poll_now.notified() => {} + } + { + // Checked under the lock `subscribe` takes, so a listener cannot join a + // watcher that is about to stop. + let mut watchers = self.watchers.lock().unwrap(); + if changes.receiver_count() == 0 { + watchers.remove(&workspace_id); + return; + } + } + match backend.runnable_list_fingerprint(&workspace_id).await { + Ok(fingerprint) => { + // The first fingerprint also notifies: the listener that started this + // watcher has already sent its opening notification, and a change landing + // before this baseline would otherwise never be announced. + if last.as_ref() != Some(&fingerprint) { + let _ = changes.send(()); + } + last = Some(fingerprint); + } + Err(e) => tracing::warn!( + "MCP tool-list poll failed for workspace {workspace_id}: {}", + e.message + ), + } + } + } +} diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index b97e374e98..ad7c14d320 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -7,6 +7,7 @@ pub mod backend; pub mod endpoints; +mod list_changes; pub mod runner; pub mod tools; diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index e27eaa8470..177989a82e 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -16,20 +16,22 @@ use crate::server::backend::{McpAuth, McpBackend, McpRequest, PathFilter}; use crate::server::endpoints::{ endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool, EndpointTool, }; +use crate::server::list_changes::ListChanges; use crate::server::tools::create_tool_from_item; use rmcp::handler::server::ServerHandler; use rmcp::model::{ CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - Implementation, InitializeResult, ListPromptsResult, ListResourceTemplatesResult, - ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, - ServerCapabilities, ServerInfo, + Implementation, InitializeRequestParams, InitializeResult, ListPromptsResult, + ListResourceTemplatesResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams, + ProtocolVersion, ServerCapabilities, ServerInfo, SubscriptionFilter, }; -use rmcp::service::{RequestContext, RoleServer}; +use rmcp::service::{RequestContext, RoleServer, SubscriptionContext}; use rmcp::ErrorData; use serde_json::Value; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use tokio::sync::broadcast; /// Protocol revisions this server is willing to speak. `2026-07-28` is served /// statelessly with per-request metadata; the older revisions keep the @@ -46,15 +48,21 @@ const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ /// leaves them unset, and a strict client rejects the response without them. /// /// Zero because nothing here is cacheable: the listing is rebuilt from the -/// workspace's scripts and flows, which change at any time, and this server -/// advertises no `listChanged` capability, so a client that cached a stale list -/// would have no way to learn it had gone stale. +/// workspace's scripts and flows, which change at any time, and `listChanged` +/// covers only some of what the listing depends on (not resources, favorites or +/// permissions), lags by up to a poll interval, and reaches only clients that hold a +/// `subscriptions/listen` stream. const LIST_TTL_MS: u64 = 0; + /// Every listing is filtered by the caller's token scopes and workspace /// membership, so no two callers necessarily see the same tools — a shared /// cache entry would leak one token's view to another. const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private; +/// How long a `subscriptions/listen` stream waits after a change before notifying, +/// collecting the rest of the burst into the same notification. +const LIST_CHANGE_COALESCE: std::time::Duration = std::time::Duration::from_millis(500); + // Re-export from http crate for extracting request parts use http::request::Parts as HttpParts; @@ -83,11 +91,12 @@ fn truncate_tool_result(text: String) -> String { /// to perform the actual operations (database queries, job execution, etc.) pub struct Runner { backend: Arc, + list_changes: Arc, } impl Clone for Runner { fn clone(&self) -> Self { - Self { backend: self.backend.clone() } + Self { backend: self.backend.clone(), list_changes: self.list_changes.clone() } } } @@ -113,7 +122,7 @@ struct McpContext { impl Runner { /// Create a new Runner with the given backend pub fn new(backend: B) -> Self { - Self { backend: Arc::new(backend) } + Self { backend: Arc::new(backend), list_changes: Arc::default() } } /// Extract authentication, the workspace mode and the HTTP request itself @@ -232,6 +241,22 @@ fn endpoint_path_policy(endpoint_name: &str) -> Option { } } +/// Endpoint tools that change which scripts/flows a workspace exposes, or their +/// schemas. The process serving the call polls the workspace right away rather than +/// at its next interval, so a client that just deployed a script can call it. +fn changes_runnable_list(endpoint_name: &str) -> bool { + matches!( + endpoint_name, + "createScript" + | "updateScript" + | "deleteScriptByPath" + | "deleteScriptByHash" + | "createFlow" + | "updateFlow" + | "deleteFlowByPath" + ) +} + /// Whether the token restricts `kind` ("script"/"flow") to specific paths. A /// `*` pattern grants every path (see `is_resource_allowed`), so it does not /// count as confinement. @@ -428,12 +453,17 @@ impl ServerHandler for Runner { .with_title("Windmill") .with_website_url("https://windmill.dev"); - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(server_info) - .with_instructions( - "This server provides a list of scripts and flows the user can run on Windmill. \ + InitializeResult::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + .with_server_info(server_info) + .with_instructions( + "This server provides a list of scripts and flows the user can run on Windmill. \ Each flow and script is a tool callable with their respective arguments.", - ) + ) } /// Pinned rather than left to rmcp's default (every version the SDK knows), so a @@ -444,6 +474,84 @@ impl ServerHandler for Runner { Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS) } + /// Only clients below `2026-07-28` send `initialize`, and those receive server + /// notifications on a session's standalone stream. The transport is sessionless, so + /// there is none: `listChanged` is withheld from them rather than promised and + /// never delivered. `2026-07-28` clients learn it from `server/discover` and + /// receive it on `subscriptions/listen` (see `listen`). + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + context.peer.set_peer_info(request.clone()); + let mut info = self.get_info(); + if let Some(tools) = info.capabilities.tools.as_mut() { + tools.list_changed = None; + } + if SUPPORTED_PROTOCOL_VERSIONS.contains(&request.protocol_version) { + info.protocol_version = request.protocol_version; + } + Ok(info) + } + + /// `None` for a request that asks for nothing this server sends, so the client is + /// told the subscription is unsupported rather than acked with an empty filter. + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + (requested.tools_list_changed == Some(true)) + .then(|| SubscriptionFilter::builder().tools_list_changed().build()) + } + + async fn listen(&self, subscription: SubscriptionContext) -> Result<(), ErrorData> { + let McpContext { auth, mode, .. } = Self::extract_context(subscription.request_context())?; + // Multi-workspace mode and read-only tokens list no scripts or flows, so their + // tool list never changes. + let workspace_id = match mode { + McpMode::Single(workspace_id) if !auth.read_only() => workspace_id, + _ => { + subscription.cancelled().await; + return Ok(()); + } + }; + + let mut changes = self.list_changes.subscribe(&self.backend, &workspace_id); + // Every stream opens with one notification: a change the watcher recorded between + // this client's `tools/list` and this subscription is never broadcast again. + let mut opening = true; + loop { + if !std::mem::take(&mut opening) { + tokio::select! { + _ = subscription.cancelled() => return Ok(()), + change = changes.recv() => match change { + Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => return Ok(()), + }, + } + } + // A client creating several scripts in a row: collapse the burst into one + // notification, so it re-lists once. + tokio::select! { + _ = subscription.cancelled() => return Ok(()), + _ = tokio::time::sleep(LIST_CHANGE_COALESCE) => {} + } + while !matches!( + changes.try_recv(), + Err(broadcast::error::TryRecvError::Empty | broadcast::error::TryRecvError::Closed) + ) {} + if subscription + .sink() + .notify_tool_list_changed() + .await + .is_err() + { + return Ok(()); + } + } + } + async fn list_tools( &self, _request: Option, @@ -714,6 +822,9 @@ impl Runner { .backend .call_endpoint(auth, workspace_id, endpoint_tool, args) .await?; + if changes_runnable_list(&endpoint_tool.name) { + self.list_changes.poll_now(workspace_id); + } return Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( @@ -969,6 +1080,9 @@ impl Runner { .backend .call_endpoint(&resolved_auth, &workspace_id, endpoint_tool, args) .await?; + if changes_runnable_list(&endpoint_tool.name) { + self.list_changes.poll_now(&workspace_id); + } Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( From d18d7043df6536e10b22ee9f3df77e926a0460b4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 12:00:49 +0200 Subject: [PATCH 03/51] feat: infer a script's schema when a deploy (e.g. MCP) sends none (#11339) * feat: infer a script's schema from its code when a deploy sends none Co-Authored-By: Claude Opus 5.5 (1M context) * fix: merge an inferred schema into the previous one the way the editor does Co-Authored-By: Claude Opus 5.5 (1M context) * fix: parse non-JSON TS defaults natively and keep the schema on a failed inference Co-Authored-By: Claude Opus 5.5 (1M context) * fix: type untyped TS params from their literal shape when the default can't be evaluated Co-Authored-By: Claude Opus 5.5 (1M context) * fix: read literal TS defaults off the AST so the server types them like the editor Co-Authored-By: Claude Opus 5.5 (1M context) * docs: word the script schema description for both create and update Co-Authored-By: Claude Opus 5.5 (1M context) * docs: note that dbt scripts derive their schema from the descriptor Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/Cargo.lock | 4 + .../generate_mcp_tools.py | 9 +- backend/parsers/windmill-parser-ts/src/lib.rs | 91 +++- .../parsers/windmill-parser-ts/tests/tests.rs | 32 ++ .../windmill-parser/src/json_schema.rs | 402 ++++++++++++++++++ backend/parsers/windmill-parser/src/lib.rs | 1 + backend/tests/script_schema_inference.rs | 115 +++++ backend/windmill-api-scripts/Cargo.toml | 4 + backend/windmill-api-scripts/src/scripts.rs | 124 ++++++ backend/windmill-api/openapi.yaml | 15 + .../src/mcp/auto_generated_endpoints.rs | 20 +- frontend/src/lib/mcpEndpointTools.ts | 20 +- 12 files changed, 817 insertions(+), 20 deletions(-) create mode 100644 backend/parsers/windmill-parser/src/json_schema.rs create mode 100644 backend/tests/script_schema_inference.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 57b15bc5a7..87bf7f64be 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15411,6 +15411,7 @@ dependencies = [ name = "windmill-api-scripts" version = "1.817.0" dependencies = [ + "anyhow", "axum 0.8.9", "chrono", "futures", @@ -15435,6 +15436,9 @@ dependencies = [ "windmill-native-triggers", "windmill-object-store", "windmill-parser", + "windmill-parser-bash", + "windmill-parser-go", + "windmill-parser-graphql", "windmill-parser-py", "windmill-parser-py-asset", "windmill-parser-sql", diff --git a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py index 72feb5392e..4c78f0524e 100644 --- a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py +++ b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py @@ -128,11 +128,16 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt r for r in body_schema['required'] if r in include_fields ] - # Apply opaque_fields: simplify listed properties to {"type": "object"} + # Apply opaque_fields: simplify listed properties to {"type": "object"}, + # keeping the description, which is all a caller learns about the field if body_schema and opaque_fields and 'properties' in body_schema: for field in opaque_fields: if field in body_schema['properties']: - body_schema['properties'][field] = {"type": "object"} + opaque = {"type": "object"} + description = body_schema['properties'][field].get('description') + if description: + opaque['description'] = description + body_schema['properties'][field] = opaque # If we have required fields specified and a body schema, update the required array if body_schema and required_fields: diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index 271ad4835b..ac75080994 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -16,11 +16,13 @@ use windmill_parser::{ use swc_common::{sync::Lrc, FileName, SourceMap, SourceMapper, Span, Spanned}; use swc_ecma_ast::{ - ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, Ident, IdentName, Lit, - MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, ObjectLit, ObjectPat, Param, Pat, Stmt, - Str, TsArrayType, TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, - TsLitType, TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, - TsTypeAnn, TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, + ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, ExprOrSpread, Ident, + IdentName, KeyValueProp, Lit, MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, + ObjectLit, ObjectPat, Param, Pat, Prop, PropName, PropOrSpread, Stmt, Str, TsArrayType, + TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, TsLitType, + TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, TsTypeAnn, + TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, UnaryExpr, + UnaryOp, }; use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax, TsSyntax}; @@ -578,6 +580,15 @@ fn parse_param( } }; + let literal = match &*right { + Expr::Object(_) | Expr::Array(_) if !skip_dflt => literal_json(&right), + _ => None, + }; + // Types an untyped object default that is not a plain literal (`{ a: x }`). + let literal_shape = match &*right { + Expr::Object(_) => Some(Typ::Object(ObjectType::new(None, None))), + _ => None, + }; let dflt = if skip_dflt { None } else { @@ -593,14 +604,19 @@ fn parse_param( Expr::Lit(Lit::Num(Number { value, .. })) => Some(serde_json::json!(value)), Expr::Lit(Lit::BigInt(BigInt { value, .. })) => Some(serde_json::json!(value)), Expr::Lit(Lit::Bool(Bool { value, .. })) => Some(Value::Bool(value)), - Expr::Object(ObjectLit { span, .. }) => eval_span(span, cm), - Expr::Array(ArrayLit { span, .. }) => eval_span(span, cm), + Expr::Object(ObjectLit { span, .. }) | Expr::Array(ArrayLit { span, .. }) => { + literal.or_else(|| eval_span(span, cm)) + } _ => None, } }; - if typ == Typ::Unknown && dflt.is_some() { - typ = json_to_typ(dflt.as_ref().unwrap(), false); + if typ == Typ::Unknown { + match &dflt { + Some(d) => typ = json_to_typ(d, false), + None if !skip_dflt => typ = literal_shape.unwrap_or(Typ::Unknown), + None => {} + } } Ok(Arg { otyp, @@ -636,6 +652,59 @@ fn parse_param( r } +/// A default written as a plain literal, read off the AST: `['a']` and `{ a: 1 }` are +/// not JSON, and outside wasm nothing can evaluate them (see `eval_sync`). +fn literal_json(expr: &Expr) -> Option { + let num = |v: f64| { + if v.fract() == 0.0 && v.abs() < 9007199254740992.0 { + Some(serde_json::json!(v as i64)) + } else { + serde_json::Number::from_f64(v).map(Value::Number) + } + }; + match expr { + Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())), + Expr::Lit(Lit::Num(n)) => num(n.value), + Expr::Lit(Lit::Bool(b)) => Some(Value::Bool(b.value)), + Expr::Lit(Lit::Null(_)) => Some(Value::Null), + Expr::Unary(UnaryExpr { op: UnaryOp::Minus, arg, .. }) => match &**arg { + Expr::Lit(Lit::Num(n)) => num(-n.value), + _ => None, + }, + Expr::Paren(p) => literal_json(&p.expr), + Expr::Array(a) => a + .elems + .iter() + .map(|e| match e { + Some(ExprOrSpread { spread: None, expr }) => literal_json(expr), + _ => None, + }) + .collect::>>() + .map(Value::Array), + Expr::Object(o) => o + .props + .iter() + .map(|p| match p { + PropOrSpread::Prop(p) => match &**p { + Prop::KeyValue(KeyValueProp { key, value }) => { + let key = match key { + PropName::Ident(i) => i.sym.to_string(), + PropName::Str(s) => s.value.to_string(), + PropName::Num(n) => n.value.to_string(), + _ => return None, + }; + Some((key, literal_json(value)?)) + } + _ => None, + }, + _ => None, + }) + .collect::>>() + .map(Value::Object), + _ => None, + } +} + fn eval_span(span: Span, cm: &Lrc) -> Option { let expr = cm .span_to_snippet(span) @@ -1361,5 +1430,7 @@ pub fn eval_sync(code: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] pub fn eval_sync(_code: &str) -> Result { - panic!("eval_sync is only available in wasm32") + // The server parses signatures with defaults too: a default that is not JSON + // (`{ a: 1 }`, `['x']`) is then left out rather than failing the parse. + Err("evaluating a JavaScript default needs the wasm build".to_string()) } diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs index 4206dd68f8..b4abc23488 100644 --- a/backend/parsers/windmill-parser-ts/tests/tests.rs +++ b/backend/parsers/windmill-parser-ts/tests/tests.rs @@ -7,6 +7,38 @@ mod tests { remove_pinned_import_specifiers, }; + /// The server parses defaults natively, where a non-JSON literal cannot be evaluated + /// but must still type and default the argument as the editor's wasm parse does. + #[test] + fn non_json_literal_defaults_parse_natively() { + let code = + "export function main(opts = { enabled: true, 'n': -1 }, tags = ['a'], o = { a: x }) {}"; + let sig = parse_deno_signature(code, false, false, None).unwrap(); + let defaults: Vec<_> = sig + .args + .iter() + .map(|a| (a.typ.clone(), a.default.clone())) + .collect(); + assert_eq!( + defaults, + vec![ + ( + Typ::Object(ObjectType::new( + None, + Some(vec![ + ObjectProperty::new("enabled".into(), Box::new(Typ::Bool)), + ObjectProperty::new("n".into(), Box::new(Typ::Int)), + ]) + )), + Some(json!({"enabled": true, "n": -1})) + ), + (Typ::List(Box::new(Typ::Str(None))), Some(json!(["a"]))), + // Not a plain literal: typed by its shape, with no default value. + (Typ::Object(ObjectType::new(None, None)), None), + ] + ); + } + #[test] fn test_imports_basic() { let code = r#" diff --git a/backend/parsers/windmill-parser/src/json_schema.rs b/backend/parsers/windmill-parser/src/json_schema.rs new file mode 100644 index 0000000000..6ca216dd89 --- /dev/null +++ b/backend/parsers/windmill-parser/src/json_schema.rs @@ -0,0 +1,402 @@ +use serde_json::{json, Map, Value}; + +use crate::{MainArgSignature, ObjectProperty, Typ}; + +/// The run-form schema of a script's `main`, derived the way the editor derives it +/// (`inferArgs` in the frontend, `argSigToJsonSchemaType` in `windmill-utils-internal`), +/// so a script deployed without a schema gets the one the editor would have sent. +/// `previous` is the schema of the version being superseded: as in the editor, it is +/// updated in place, so what the user set on it (argument order, descriptions, ...) +/// survives wherever the code still agrees with it. +pub fn main_arg_schema(sig: &MainArgSignature, previous: Option<&Value>) -> Value { + let mut schema = match previous { + Some(Value::Object(p)) => p.clone(), + _ => json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + }) + .as_object() + .cloned() + .unwrap_or_default(), + }; + let mut old_props = match schema.remove("properties") { + Some(Value::Object(p)) => p, + _ => Map::new(), + }; + let mut properties = Map::new(); + let mut required = vec![]; + for arg in &sig.args { + let mut prop = match old_props.remove(&arg.name) { + Some(Value::Object(p)) => p, + _ => fresh_property(), + }; + if !matches!(arg.typ, Typ::OneOf(_)) { + prop.remove("oneOf"); + } + apply_typ(&arg.typ, &mut prop); + // `T | T[]` marks an argument debouncing accumulates into. + if let Some(otyp) = arg + .otyp + .as_deref() + .filter(|o| o.contains('[') && o.contains('|')) + { + prop.insert("originalType".into(), json!(otyp)); + } + match &arg.default { + Some(d) => prop.insert("default".into(), d.clone()), + None => prop.remove("default"), + }; + if !arg.has_default { + required.push(json!(arg.name)); + } + properties.insert(arg.name.clone(), Value::Object(prop)); + } + schema.insert("properties".into(), Value::Object(properties)); + schema.insert("required".into(), Value::Array(required)); + if sig.has_cmd_binding == Some(true) { + schema.insert("x-windmill-ps-cmd-binding".into(), json!(true)); + schema.insert( + "x-windmill-ps-supports-should-process".into(), + json!(sig.supports_should_process.unwrap_or(false)), + ); + } else { + schema.remove("x-windmill-ps-cmd-binding"); + schema.remove("x-windmill-ps-supports-should-process"); + } + Value::Object(schema) +} + +/// Keys a user sets on a property in the schema editor, kept whatever the code says. +const USER_FIELDS: &[&str] = &[ + "description", + "pattern", + "min", + "max", + "currency", + "currencyLocale", + "multiselect", + "customErrorMessage", + "required", + "showExpr", + "password", + "order", + "dateFormat", + "title", + "placeholder", +]; + +/// Item keys a list the parser cannot see into keeps from the previous schema. +const ITEMS_USER_FIELDS: &[&str] = &[ + "properties", + "required", + "additionalProperties", + "enum", + "resourceType", + "contentEncoding", + "description", +]; + +fn fresh_property() -> Map { + let mut p = Map::new(); + p.insert("description".into(), json!("")); + p.insert("type".into(), json!("")); + p +} + +fn object_properties( + props: &[ObjectProperty], + old: Option<&Map>, +) -> Map { + props + .iter() + .map(|p| { + let mut prop = old + .and_then(|o| o.get(&p.key)) + .and_then(Value::as_object) + .cloned() + .unwrap_or_else(fresh_property); + apply_typ(&p.typ, &mut prop); + (p.key.clone(), Value::Object(prop)) + }) + .collect() +} + +fn kept_items(old: &Map) -> Value { + let old_items = old.get("items").and_then(Value::as_object); + let mut items = Map::new(); + items.insert( + "type".into(), + old_items + .and_then(|i| i.get("type")) + .filter(|t| t.as_str().is_some_and(|t| !t.is_empty())) + .cloned() + .unwrap_or(json!("object")), + ); + if let Some(old_items) = old_items { + for f in ITEMS_USER_FIELDS { + if let Some(v) = old_items.get(*f) { + items.insert(f.to_string(), v.clone()); + } + } + } + Value::Object(items) +} + +/// Port of `argSigToJsonSchemaType`: rewrites `old` in place to carry `typ`. +fn apply_typ(typ: &Typ, old: &mut Map) { + let mut new = Map::new(); + // Keys the editor sets to `undefined`, which drops them from the stored JSON. + let mut unset: Vec<&str> = vec![]; + let mut set = |k: &str, v: Value| { + new.insert(k.to_string(), v); + }; + match typ { + Typ::Int => set("type", json!("integer")), + Typ::Float => set("type", json!("number")), + Typ::Bool => set("type", json!("boolean")), + Typ::Email => { + set("type", json!("string")); + set("format", json!("email")); + } + Typ::Sql => { + set("type", json!("string")); + set("format", json!("sql")); + } + Typ::Bytes => { + set("type", json!("string")); + set("contentEncoding", json!("base64")); + set("originalType", json!("bytes")); + } + Typ::Datetime => { + set("type", json!("string")); + set("format", json!("date-time")); + } + Typ::Date => { + set("type", json!("string")); + set("format", json!("date")); + } + Typ::OneOf(variants) => { + set("type", json!("object")); + let old_variants = old.get("oneOf").and_then(Value::as_array); + let one_of: Vec = variants + .iter() + .map(|v| { + let old_variant = old_variants + .and_then(|o| o.iter().find(|o| o.get("title") == Some(&json!(v.label)))) + .and_then(Value::as_object); + let old_props = old_variant + .and_then(|o| o.get("properties")) + .and_then(Value::as_object); + let mut variant = json!({ + "type": "object", + "title": v.label, + "properties": object_properties(&v.properties, old_props), + }); + if let Some(order) = old_variant.and_then(|o| o.get("order")) { + variant["order"] = order.clone(); + } + variant + }) + .collect(); + set("oneOf", Value::Array(one_of)); + } + Typ::Object(o) => { + set("type", json!("object")); + if let Some(name) = &o.name { + set("format", json!(format!("resource-{name}"))); + } + if let Some(props) = &o.props { + let old_props = old.get("properties").and_then(Value::as_object); + set( + "properties", + Value::Object(object_properties(props, old_props)), + ); + } + } + Typ::Str(variants) => { + set("type", json!("string")); + match variants { + Some(v) => { + set("originalType", json!("enum")); + set("enum", json!(v)); + } + None => { + set("originalType", json!("string")); + match old.get("enum") { + Some(e) if old.get("originalType") == Some(&json!("string")) => { + set("enum", e.clone()) + } + _ => unset.push("enum"), + } + } + } + } + Typ::Resource(name) => { + set("type", json!("object")); + set("format", json!(format!("resource-{name}"))); + } + Typ::DynSelect(name) => { + set("type", json!("object")); + set("format", json!(format!("dynselect-{name}"))); + } + Typ::DynMultiselect(name) => { + set("type", json!("object")); + set("format", json!(format!("dynmultiselect-{name}"))); + } + Typ::List(inner) => { + set("type", json!("array")); + let (items, original_type) = match inner.as_ref() { + Typ::Int | Typ::Float => (json!({"type": "number"}), "number[]"), + Typ::Bytes => ( + json!({"type": "string", "contentEncoding": "base64"}), + "bytes[]", + ), + Typ::Str(Some(v)) => (json!({"type": "string", "enum": v}), "enum[]"), + Typ::Str(None) => { + let mut items = json!({"type": "string"}); + if let Some(e) = old.get("items").and_then(|i| i.get("enum")) { + items["enum"] = e.clone(); + } + (items, "string[]") + } + Typ::Resource(r) => (json!({"type": "resource", "resourceType": r}), "resource[]"), + Typ::Object(o) => { + if let Some(name) = &o.name { + set("format", json!(format!("resource-{name}"))); + } + let items = match o.props.as_deref().filter(|p| !p.is_empty()) { + Some(props) => json!({ + "type": "object", + "properties": object_properties(props, None), + }), + None => kept_items(old), + }; + (items, "record[]") + } + _ => (kept_items(old), "object[]"), + }; + set("items", items); + set("originalType", json!(original_type)); + } + // The parser cannot tell: whatever type the user gave the argument stands. + Typ::Unknown => set("type", old.get("type").cloned().unwrap_or(json!("object"))), + } + + for f in USER_FIELDS { + if let Some(v) = old.get(*f) { + new.insert(f.to_string(), v.clone()); + } + } + + let old_type = old.get("type").cloned(); + let new_type = new.get("type").cloned(); + let old_format = old + .get("format") + .and_then(Value::as_str) + .map(str::to_string); + let new_format = new + .get("format") + .and_then(Value::as_str) + .map(str::to_string); + let is_date = |f: &Option| matches!(f.as_deref(), Some("date" | "date-time")); + let mut keep_old_format = false; + // A changed type takes every key `new` sets, so none of the adjustments below + // apply: running them would carry the old type's date format or items over. + if old_type != new_type { + } else if new_format.as_deref() == Some("date-time") && old_format.as_deref() == Some("date") { + new.insert("format".into(), json!("date")); + } else if is_date(&new_format) { + keep_old_format = true; + } else if old.get("items").and_then(|i| i.get("type")) + != new.get("items").and_then(|i| i.get("type")) + { + old.remove("items"); + } else if old_type == Some(json!("string")) && old_format.is_some() { + keep_old_format = true; + } + if keep_old_format { + match old.get("format") { + Some(f) => { + new.insert("format".into(), f.clone()); + } + None => { + new.remove("format"); + unset.push("format"); + } + } + } + if (old_type != new_type || new_type != Some(json!("string"))) && !new.contains_key("format") { + old.remove("format"); + } + + for k in unset { + if !new.contains_key(k) { + old.remove(k); + } + } + old.extend(new); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Arg, ObjectType}; + + fn arg(name: &str, typ: Typ, default: Option) -> Arg { + Arg { + name: name.to_string(), + has_default: default.is_some(), + default, + typ, + ..Default::default() + } + } + + #[test] + fn keeps_what_the_user_set_on_the_previous_version() { + let sig = MainArgSignature { + args: vec![ + arg("n", Typ::Int, None), + arg("db", Typ::Resource("postgresql".into()), None), + arg( + "tags", + Typ::List(Box::new(Typ::Str(None))), + Some(json!(["a"])), + ), + arg( + "opts", + Typ::Object(ObjectType::new(None, None)), + Some(json!({})), + ), + arg("any", Typ::Unknown, None), + ], + ..Default::default() + }; + let previous = json!({ + "type": "object", + "order": ["db", "n"], + "properties": { + "n": {"type": "integer", "description": "how many", "min": 1, "nullable": true}, + "tags": {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}}, + "gone": {"type": "string"}, + }, + }); + let schema = main_arg_schema(&sig, Some(&previous)); + assert_eq!(schema["order"], json!(["db", "n"])); + assert_eq!(schema["required"], json!(["n", "db", "any"])); + assert_eq!( + schema["properties"]["n"], + json!({"type": "integer", "description": "how many", "min": 1, "nullable": true}) + ); + assert!(schema["properties"].get("gone").is_none()); + assert_eq!(schema["properties"]["db"]["format"], "resource-postgresql"); + assert_eq!( + schema["properties"]["tags"]["items"], + json!({"type": "string", "enum": ["a", "b"]}) + ); + assert_eq!(schema["properties"]["tags"]["default"], json!(["a"])); + assert_eq!(schema["properties"]["opts"]["type"], "object"); + // Untyped in the code and new: any JSON value, as the editor stores it. + assert_eq!(schema["properties"]["any"]["type"], ""); + } +} diff --git a/backend/parsers/windmill-parser/src/lib.rs b/backend/parsers/windmill-parser/src/lib.rs index 4270edcc79..7ca226b432 100644 --- a/backend/parsers/windmill-parser/src/lib.rs +++ b/backend/parsers/windmill-parser/src/lib.rs @@ -15,6 +15,7 @@ use serde_json::Value; pub mod asset_parser; pub mod duckdb_builtins; pub mod duckdb_macros; +pub mod json_schema; pub mod sql_materialize; /// S3 output format for SQL queries (moved here to avoid pulling sqlx into WASM via windmill-types) diff --git a/backend/tests/script_schema_inference.rs b/backend/tests/script_schema_inference.rs new file mode 100644 index 0000000000..083716f8df --- /dev/null +++ b/backend/tests/script_schema_inference.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; +use windmill_api_client::types::{NewScript, ScriptLang}; +use windmill_test_utils::init_client; + +const PATH: &str = "u/test-user/inferred_schema"; + +fn bun_ns(content: &str, parent_hash: Option) -> NewScript { + NewScript { + draft_only: None, + content: content.into(), + language: ScriptLang::Bun, + lock: None, + parent_hash, + path: PATH.into(), + concurrent_limit: None, + concurrency_time_window_s: None, + cache_ttl: None, + dedicated_worker: None, + description: "".to_string(), + envs: vec![], + is_template: None, + kind: None, + summary: "".to_string(), + tag: None, + // What this client sends for "no schema". + schema: HashMap::new(), + ws_error_handler_muted: Some(false), + priority: None, + delete_after_secs: None, + timeout: None, + restart_unless_cancelled: None, + deployment_message: None, + concurrency_key: None, + visible_to_runner_only: None, + auto_kind: None, + codebase: None, + has_preprocessor: None, + on_behalf_of_email: None, + assets: vec![], + modules: None, + } +} + +async fn head(db: &Pool) -> anyhow::Result<(i64, Value)> { + Ok(sqlx::query_as( + "SELECT hash, schema::jsonb FROM script \ + WHERE workspace_id = 'test-workspace' AND path = $1 AND archived = false", + ) + .bind(PATH) + .fetch_one(db) + .await?) +} + +/// A deploy without a schema (MCP, a bare API call) gets the one the editor would +/// have sent, and a new version keeps what the user wrote on the previous one's. +#[sqlx::test(fixtures("base"))] +async fn schemaless_deploy_infers_schema_keeping_annotations( + db: Pool, +) -> anyhow::Result<()> { + let (client, _port, _s) = init_client(db.clone()).await; + + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number, name = \"x\") { return n }", + None, + ), + ) + .await?; + let (hash, schema) = head(&db).await?; + assert_eq!(schema["required"], json!(["n"])); + assert_eq!(schema["properties"]["n"]["type"], "number"); + assert_eq!(schema["properties"]["name"]["default"], "x"); + + sqlx::query( + "UPDATE script SET schema = jsonb_set(schema::jsonb, '{properties,n,description}', \ + '\"how many\"')::json WHERE hash = $1", + ) + .bind(hash) + .execute(&db) + .await?; + + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number, flag: boolean) { return n }", + Some(format!("{hash:016x}")), + ), + ) + .await?; + let (hash, schema) = head(&db).await?; + assert_eq!(schema["required"], json!(["n", "flag"])); + assert_eq!(schema["properties"]["n"]["description"], "how many"); + assert_eq!(schema["properties"]["flag"]["type"], "boolean"); + assert!(schema["properties"].get("name").is_none()); + + // Code that does not parse keeps the schema rather than losing it for good. + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number {", + Some(format!("{hash:016x}")), + ), + ) + .await?; + assert_eq!(head(&db).await?.1, schema); + + Ok(()) +} diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml index 8b34512872..72353c6f1b 100644 --- a/backend/windmill-api-scripts/Cargo.toml +++ b/backend/windmill-api-scripts/Cargo.toml @@ -30,7 +30,11 @@ windmill-parser-ts-asset.workspace = true windmill-parser-sql-asset.workspace = true windmill-parser-sql.workspace = true windmill-parser-yaml.workspace = true +windmill-parser-bash.workspace = true +windmill-parser-go.workspace = true +windmill-parser-graphql.workspace = true +anyhow.workspace = true axum.workspace = true futures.workspace = true http.workspace = true diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 51f2619805..631e8606e6 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -1132,6 +1132,96 @@ async fn validate_dbt_relation( }) } +/// The schema the editor would have derived from `content`, for a deploy that sends +/// none (MCP, a bare API call). `None` for a language whose parser this build lacks, +/// or code that does not parse. +fn infer_main_schema( + language: &ScriptLang, + kind: Option<&ScriptKind>, + content: &str, + previous: Option<&serde_json::Value>, +) -> Option { + let entrypoint = + matches!(kind, Some(ScriptKind::Preprocessor)).then(|| "preprocessor".to_string()); + // The database a SQL script runs against is an argument unless the code names + // it (`-- database f/...`), as in the editor. + let with_db = |sig: anyhow::Result, resource: &str| { + sig.map(|mut sig| { + if windmill_parser_sql::parse_db_resource(content).is_none() { + sig.args.insert( + 0, + windmill_parser::Arg { + name: "database".to_string(), + typ: windmill_parser::Typ::Resource(resource.to_string()), + ..Default::default() + }, + ); + } + sig + }) + }; + let sig = match language { + ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { + windmill_parser_ts::parse_deno_signature(content, false, false, entrypoint) + } + #[cfg(feature = "python")] + ScriptLang::Python3 => { + windmill_parser_py::parse_python_signature(content, entrypoint, false) + } + ScriptLang::Go => windmill_parser_go::parse_go_sig(content), + ScriptLang::Bash => windmill_parser_bash::parse_bash_sig(content), + ScriptLang::Powershell => windmill_parser_bash::parse_powershell_sig(content), + ScriptLang::Postgresql => { + with_db(windmill_parser_sql::parse_pgsql_sig(content), "postgresql") + } + ScriptLang::Mysql => with_db(windmill_parser_sql::parse_mysql_sig(content), "mysql"), + ScriptLang::Bigquery => { + with_db(windmill_parser_sql::parse_bigquery_sig(content), "bigquery") + } + ScriptLang::Snowflake => with_db( + windmill_parser_sql::parse_snowflake_sig(content), + "snowflake", + ), + ScriptLang::Mssql => with_db( + windmill_parser_sql::parse_mssql_sig(content), + "ms_sql_server", + ), + ScriptLang::OracleDB => { + with_db(windmill_parser_sql::parse_oracledb_sig(content), "oracledb") + } + ScriptLang::DuckDb => windmill_parser_sql::parse_duckdb_sig(content), + ScriptLang::Graphql => { + windmill_parser_graphql::parse_graphql_sig(content).map(|mut sig| { + sig.args.insert( + 0, + windmill_parser::Arg { + name: "api".to_string(), + typ: windmill_parser::Typ::Resource("graphql".to_string()), + ..Default::default() + }, + ); + sig + }) + } + ScriptLang::Ansible => windmill_parser_yaml::parse_ansible_sig(content), + _ => return None, + }; + match sig { + Ok(sig) => serde_json::value::to_raw_value(&windmill_parser::json_schema::main_arg_schema( + &sig, previous, + )) + .ok() + .map(|v| Schema(sqlx::types::Json(v))), + Err(e) => { + tracing::warn!( + "could not infer the schema of a {} script: {e:#}", + language.as_str() + ); + None + } + } +} + async fn create_script_internal<'c>( mut ns: NewScript, w_id: String, @@ -1359,6 +1449,40 @@ async fn create_script_internal<'c>( parent_adopted_from_retired_path = ns.parent_hash.is_some(); } + // Before hashing, so the hash and the no-op check see the schema that gets stored. + // `{}` counts as absent: an agent filling every tool argument sends it for "none". + let schema_absent = ns.schema.as_ref().is_none_or(|s| { + serde_json::from_str::>(s.0.get()) + .is_ok_and(|m| m.is_empty()) + }); + if schema_absent && !matches!(ns.language, ScriptLang::Dbt) { + let previous = match &ns.parent_hash { + Some(p_hash) => sqlx::query_scalar::<_, Option>>( + "SELECT schema FROM script WHERE hash = $1 AND workspace_id = $2", + ) + .bind(p_hash.0) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await? + .flatten() + .map(|s| s.0), + None => None, + }; + // Code the server cannot parse keeps the previous version's schema, as it + // does in the editor, rather than losing it for good. + ns.schema = infer_main_schema( + &ns.language, + ns.kind.as_ref(), + &ns.content, + previous.as_ref(), + ) + .or_else(|| { + previous + .and_then(|p| serde_json::value::to_raw_value(&p).ok()) + .map(|v| Schema(sqlx::types::Json(v))) + }); + } + // Must stay below the parent resolution above: an auto_parent deploy hashed before // it carries a first deploy's lineage, so redeploying content the path has held // before collides with that archived version instead of superseding it. The diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 016342a6fe..4346dfcd0a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10685,6 +10685,9 @@ paths: - kind - tag - deployment_message + - schema + x-mcp-tool-opaque-fields: + - schema tags: - script parameters: @@ -10724,6 +10727,9 @@ paths: - kind - tag - deployment_message + - schema + x-mcp-tool-opaque-fields: + - schema tags: - script parameters: @@ -29742,6 +29748,15 @@ components: type: string schema: type: object + description: >- + JSON Schema of the arguments of `main`, which is what a run form and an + MCP tool offer. Omitted (or `{}`), it is inferred from `content` for + TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible + scripts. For other languages, or code that does not parse, a new script + gets none. A new version of an existing script also keeps what the + previous version's schema says about each argument, or that whole schema + when nothing can be inferred. A dbt script always takes its schema from its + descriptor, whatever is sent. is_template: type: boolean lock: diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index ffefaba2dd..1962710578 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -699,6 +699,10 @@ pub fn all_tools() -> Vec { "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -757,6 +761,10 @@ is, a different one moves it there and archives the old path"), "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -1024,10 +1032,12 @@ is, a different one moves it there and archives the old path"), "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "type": "object", + "description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')" }, "path": { "type": "string" @@ -1080,10 +1090,12 @@ is, a different one moves it there and archives the old path"), "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "type": "object", + "description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')" }, "tag": { "type": "string" diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index a9e2b9f3b2..2197998857 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -708,6 +708,10 @@ export const mcpEndpointTools: EndpointTool[] = [ "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -764,6 +768,10 @@ export const mcpEndpointTools: EndpointTool[] = [ "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -1031,10 +1039,12 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "type": "object", + "description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')" }, "path": { "type": "string" @@ -1087,10 +1097,12 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "type": "object", + "description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')" }, "tag": { "type": "string" From bc4f872f1093a59b9b8fbc3e11a6f00b42474f0c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 12:30:43 +0200 Subject: [PATCH 04/51] perf: skip the flow_env ancestor walk for sub-flows with nothing to inherit (#11349) * perf: skip the flow_env ancestor walk for sub-flows with nothing to inherit Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the flow_env mark out of sub-flow definitions replayed by restarts Co-Authored-By: Claude Opus 5.5 (1M context) * test: align the guest scopable-path test with app path validation Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/tests/app_guest_execution_mode.rs | 27 ++--- backend/tests/flow_engine_parity.rs | 125 +++++++++++++++++++++ backend/tests/job_payload.rs | 1 + backend/windmill-queue/src/jobs.rs | 6 +- backend/windmill-types/src/flow_status.rs | 6 + backend/windmill-types/src/jobs.rs | 2 + backend/windmill-worker/src/worker_flow.rs | 86 +++++++++----- 7 files changed, 209 insertions(+), 44 deletions(-) diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs index 7af2e0794e..105458847a 100644 --- a/backend/tests/app_guest_execution_mode.rs +++ b/backend/tests/app_guest_execution_mode.rs @@ -577,24 +577,13 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 400, "renamed to a `,` path while in Guests"); - let resp = authed( - client().post(format!("{ws}/apps/update/{APP_PATH}")), - ADMIN_TOKEN, - ) - .json(&json!({ "path": "u/test-user/My App" })) - .send() - .await?; - assert_eq!( - resp.status(), - 200, - "a space is literal: {}", - resp.text().await? - ); - // Set on update: an app that already sits on such a path cannot be switched. + // Set on update: an app that already sits on such a path cannot be switched. Path + // validation now refuses these paths on every write, so plant the kind of app saved + // before it did. let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) .json(&json!({ - "path": "u/test-user/x:y", + "path": "u/test-user/legacy", "summary": "App", "value": {}, "policy": { "execution_mode": "publisher", "triggerables_v2": {} } @@ -602,6 +591,12 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 201, "{}", resp.text().await?); + sqlx::query( + "UPDATE app SET path = 'u/test-user/x:y' + WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/legacy'", + ) + .execute(&db) + .await?; let resp = authed( client().post(format!("{ws}/apps/update/u/test-user/x:y")), ADMIN_TOKEN, @@ -610,6 +605,8 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 400, "switched to Guests on a `:` path"); + let body = resp.text().await?; + assert!(body.contains("cannot be set to Guests"), "{body}"); Ok(()) } diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index b6a943a23b..7ea221c2e8 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -2877,6 +2877,131 @@ async fn test_flow_env_imported_flow_with_nested_branch(db: Pool) -> a Ok(()) } +// A branch/loop sub-flow of a deployed flow is marked at push when its parent has no flow_env, +// own or inherited, so its worker skips `fetch_root_flow_env`. With an env two scopes up (root +// → loop → imported flow without env → parallel loop → leaf) nothing may be marked and the +// root's value must reach the leaf; with no env anywhere every loop iteration is marked. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_marks_sub_flows_only_without_ancestor_env( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Deployed, so their loops run as `FlowNode` sub-flows. + let deploy = |path: &'static str, version: i64, value: serde_json::Value| { + let db = db.clone(); + async move { + sqlx::query( + "INSERT INTO flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ('test-workspace', '', '', $1, ARRAY[$2]::bigint[], '{}'::jsonb, $3, 'system')", + ) + .bind(path) + .bind(version) + .bind(&value) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, 'test-workspace', $2, '{}'::jsonb, $3, 'system')", + ) + .bind(version) + .bind(path) + .bind(&value) + .execute(&db) + .await?; + RunJob::from(JobPayload::FlowDependencies { + path: path.to_string(), + dedicated_worker: None, + version, + debouncing_settings: Default::default(), + }) + .run_until_complete(&db, false, port) + .await; + anyhow::Ok(()) + } + }; + deploy( + "f/system/imported_loop_no_env", + 9991004, + json!({ "modules": [{ "id": "inner", "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "[1, 2]" }, + "skip_failures": false, + "parallel": true, + "modules": [{ "id": "leaf", "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main(key: any) { return key ?? null; }", + "input_transforms": { "key": { "type": "javascript", "expr": "flow_env.KEY" } } + }}, { "id": "tail", "value": { + // A single simple step is inlined rather than stored as a flow node. + "type": "rawscript", + "language": "deno", + "content": "export function main(prev: any) { return prev; }", + "input_transforms": { "prev": { "type": "javascript", "expr": "results.leaf" } } + }}] + }}]}), + ) + .await?; + let root = |flow_env: serde_json::Value| { + json!({ "flow_env": flow_env, "modules": [{ "id": "outer", "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "[1]" }, + "skip_failures": false, + "parallel": false, + "modules": [{ "id": "import", "value": { + "type": "flow", + "path": "f/system/imported_loop_no_env", + "input_transforms": {} + }}] + }}]}) + }; + deploy( + "f/system/root_with_env", + 9991005, + root(json!({ "KEY": "from-root" })), + ) + .await?; + deploy("f/system/root_no_env", 9991006, root(json!(null))).await?; + + let run = |path: &'static str, version: i64| { + let db = db.clone(); + async move { + let completed = RunJob::from(JobPayload::Flow { + path: path.to_string(), + dedicated_worker: None, + apply_preprocessor: false, + version, + labels: None, + }) + .run_until_complete(&db, false, port) + .await; + // The mark of every loop iteration pushed under the run. + let marks = sqlx::query_as::<_, (String, Option)>( + "SELECT j.kind::text, (c.flow_status->>'no_inherited_flow_env')::bool + FROM v2_job j JOIN v2_job_completed c ON c.id = j.id + WHERE COALESCE(j.root_job, j.parent_job) = $1 + AND j.flow_step_id IN ('outer', 'inner')", + ) + .bind(completed.id) + .fetch_all(&db) + .await?; + anyhow::Ok((completed.json_result().unwrap(), marks)) + } + }; + + let (result, marks) = run("f/system/root_with_env", 9991005).await?; + assert_eq!(result, json!([["from-root", "from-root"]])); + assert_eq!(marks, vec![("flownode".to_string(), None); 3]); + + let (result, marks) = run("f/system/root_no_env", 9991006).await?; + assert_eq!(result, json!([[null, null]])); + assert_eq!(marks, vec![("flownode".to_string(), Some(true)); 3]); + + Ok(()) +} + // stop_after_if predicate sees flow_env. Regression for the eval at line 614 // of `update_flow_status_after_job_completion_internal` which used to pass // `None` for flow_env unconditionally. diff --git a/backend/tests/job_payload.rs b/backend/tests/job_payload.rs index c538cbe6e1..1a973f6fe6 100644 --- a/backend/tests/job_payload.rs +++ b/backend/tests/job_payload.rs @@ -239,6 +239,7 @@ mod job_payload { let result = RunJob::from(JobPayload::FlowNode { id, path: "f/system/hello_with_nodes_flow/forloop-0".into(), + no_inherited_flow_env: false, }) .arg("iter", json!({ "value": "tests", "index": 0 })) .run_until_complete(&db, false, port) diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 50eb9c8b1f..18fe58becc 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -5854,10 +5854,10 @@ async fn push_inner<'c, 'd>( dedicated_worker, ..Default::default() }, - JobPayload::FlowNode { id, path } => { + JobPayload::FlowNode { id, path, no_inherited_flow_env } => { let data = cache::flow::fetch_flow(db, id).await?; let value = data.value(); - let status = Some(FlowStatus::new(value)); + let status = Some(FlowStatus { no_inherited_flow_env, ..FlowStatus::new(value) }); // Keep inserting `value` if not all workers are updated. // Starting at `v1.440`, the value is fetched on pull from the flow node id. let value_o = if !MIN_VERSION_IS_AT_LEAST_1_440.met().await { @@ -6094,6 +6094,7 @@ async fn push_inner<'c, 'd>( stream_job: None, chat_input_enabled: None, memory_id: None, + no_inherited_flow_env: false, } } _ => { @@ -6488,6 +6489,7 @@ async fn push_inner<'c, 'd>( stream_job: None, chat_input_enabled: None, memory_id: None, + no_inherited_flow_env: false, }; let value = flow_data.value(); let priority = value.priority; diff --git a/backend/windmill-types/src/flow_status.rs b/backend/windmill-types/src/flow_status.rs index 79890e3c6e..6dff8430b7 100644 --- a/backend/windmill-types/src/flow_status.rs +++ b/backend/windmill-types/src/flow_status.rs @@ -41,6 +41,11 @@ pub struct FlowStatus { pub chat_input_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory_id: Option, + /// Set at push on a branch/loop sub-flow whose enclosing flow had no `flow_env`, own or + /// inherited, so the worker can skip the ancestor walk for it. `false` only means unknown: + /// the worker then walks the ancestors as usual. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub no_inherited_flow_env: bool, } #[derive(Serialize, Deserialize, Debug, Clone, Default)] @@ -568,6 +573,7 @@ impl FlowStatus { stream_job: None, chat_input_enabled: f.chat_input_enabled, memory_id: None, + no_inherited_flow_env: false, } } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 5190efe380..4e0c5340f5 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -519,6 +519,8 @@ pub enum JobPayload { FlowNode { id: FlowNodeId, path: String, + /// See [`crate::flow_status::FlowStatus::no_inherited_flow_env`]. + no_inherited_flow_env: bool, }, FlowScript { id: FlowNodeId, diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 0ab4e2871c..6e01378448 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -537,15 +537,22 @@ pub async fn update_flow_status_after_job_completion_internal( if let Some(cached) = RESOLVED_FLOW_ENV_CACHE.get(&flow) { Some(cached) } else { - resolve_flow_env_for_status_update(db, client, flow, w_id, flow_value) - .await - .map(|(env, is_cacheable)| { - let arc = Arc::new(env); - if is_cacheable { - RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); - } - arc - }) + resolve_flow_env_for_status_update( + db, + client, + flow, + w_id, + flow_value, + old_status.no_inherited_flow_env, + ) + .await + .map(|(env, is_cacheable)| { + let arc = Arc::new(env); + if is_cacheable { + RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); + } + arc + }) } } else { None @@ -2521,6 +2528,7 @@ async fn resolve_flow_env_for_status_update( flow_job_id: Uuid, workspace_id: &str, flow_value: &FlowValue, + no_inherited_flow_env: bool, ) -> Option<(HashMap>, bool)> { // Fetch the env source. For the inherited path, we first need to know whether the // flow even has a parent — `fetch_root_flow_env` runs a recursive CTE on `v2_job` @@ -2529,6 +2537,8 @@ async fn resolve_flow_env_for_status_update( let (env, mini): (HashMap>, Option) = if let Some(ref e) = flow_value.flow_env { (e.clone(), None) + } else if no_inherited_flow_env { + return None; } else { let mini = match get_mini_pulled_job(db, &flow_job_id).await { Ok(Some(j)) => j, @@ -2542,7 +2552,13 @@ async fn resolve_flow_env_for_status_update( // No own flow_env and no parent to inherit from — nothing to resolve. return None; } - let env = fetch_root_flow_env(db, flow_job_id, workspace_id).await?; + let env = match fetch_root_flow_env(db, flow_job_id, workspace_id).await { + Ok(env) => env?, + Err(e) => { + tracing::warn!("Failed to look up the inherited flow_env: {e:#}"); + return None; + } + }; (env, Some(mini)) }; if env.is_empty() { @@ -2608,7 +2624,7 @@ async fn fetch_root_flow_env( db: &DB, flow_job_id: Uuid, workspace_id: &str, -) -> Option>> { +) -> Result>>, sqlx::Error> { sqlx::query_scalar!( r#"WITH RECURSIVE chain(id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, depth) AS ( SELECT id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, 0 @@ -2645,10 +2661,7 @@ async fn fetch_root_flow_env( ) .fetch_optional(db) .await - .ok() - .flatten() - .flatten() - .map(|json| json.0) + .map(|row| row.flatten().map(|json| json.0)) } struct FailureContext { @@ -2793,18 +2806,31 @@ pub async fn handle_flow( ) -> anyhow::Result<()> { let flow = flow_data.value(); + let status = flow_job + .parse_flow_status() + .with_context(|| "Unable to parse flow status")?; + // Sub-flows spawned by `payload_from_modules` for branches/loops don't // carry the parent's `flow_env` in their own FlowValue. Fall back to the // nearest enclosing scope's `flow_env` so predicates like `skip_if`, // `stop_after_if`, and branch conditions see the same env as input // transforms. - let inherited_env: Option>> = - if flow.flow_env.is_none() && flow_job.parent_job.is_some() { - fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await - } else { - None - }; + let (inherited_env, inherited_env_known) = if flow.flow_env.is_none() + && flow_job.parent_job.is_some() + && !status.no_inherited_flow_env + { + match fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await { + Ok(env) => (env, true), + Err(e) => { + tracing::warn!("Failed to look up the inherited flow_env: {e:#}"); + (None, false) + } + } + } else { + (None, true) + }; let env_source = flow.flow_env.as_ref().or(inherited_env.as_ref()); + let no_flow_env = inherited_env_known && env_source.is_none_or(|e| e.is_empty()); // Resolve $var: and $res: references in flow_env. // We resolve into a separate variable to avoid cloning the entire FlowValue @@ -2850,10 +2876,6 @@ pub async fn handle_flow( } } - let status = flow_job - .parse_flow_status() - .with_context(|| "Unable to parse flow status")?; - let schedule_path = flow_job.schedule_path(); if !flow_job.is_flow_step() && status.retry.fail_count == 0 @@ -2997,6 +3019,7 @@ pub async fn handle_flow( status, flow, flow_env, + no_flow_env, db, client, last_result.clone(), @@ -3240,6 +3263,8 @@ async fn push_next_flow_job( mut status: FlowStatus, flow: &FlowValue, flow_env: Option<&HashMap>>, + // Neither this flow nor any ancestor defines `flow_env`. + no_flow_env: bool, db: &sqlx::Pool, client: &AuthedClient, last_job_result: Option>>, @@ -4229,7 +4254,14 @@ async fn push_next_flow_job( let mut tx = db.begin().warn_after_seconds(3).await?; let nargs = args.as_ref(); - for (i, payload_tag) in job_payloads.into_iter().enumerate() { + for (i, mut payload_tag) in job_payloads.into_iter().enumerate() { + // A `FlowNode` here is a branch/loop sub-flow from `payload_from_modules`: it never defines + // its own `flow_env`, so it inherits exactly this flow's. The mark lives in the child's + // status, not its definition: a `RawFlow` child's stored definition is replayed by nested + // restarts, whose root may have gained a `flow_env` since. + if let JobPayload::FlowNode { no_inherited_flow_env, .. } = &mut payload_tag.payload { + *no_inherited_flow_env = no_flow_env; + } if i % 100 == 0 && i != 0 { tracing::info!(id = %flow_job.id, root_id = %job_root, "pushed (non-commited yet) first {i} subflows of {len}"); // Ping on the pool, outside `tx`, so the zombie flow monitor sees it before the @@ -5218,7 +5250,7 @@ fn payload_from_modules<'a>( } if let Some(id) = modules_node { - return Some(JobPayload::FlowNode { id, path: path() }); + return Some(JobPayload::FlowNode { id, path: path(), no_inherited_flow_env: false }); } add_virtual_items_if_necessary(&mut modules); From 245628210f7d75aedb9e0a289d1dee63f1513b5b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 12:41:27 +0200 Subject: [PATCH 05/51] perf: skip parent status write when a parallel loop iteration starts (#11348) * perf: skip parent status write when a parallel loop iteration starts Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the loop viewer off the parent job while a parallel loop runs Co-Authored-By: Claude Opus 5.5 (1M context) * docs: state what a parallel module's job would hold Co-Authored-By: Claude Opus 5.5 (1M context) * perf: test the parallel module with one containment check Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json} | 4 ++-- backend/windmill-queue/src/flow_status.rs | 9 ++++++++- .../src/lib/components/FlowStatusViewerInner.svelte | 11 ++++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) rename backend/.sqlx/{query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json => query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json} (60%) diff --git a/backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json b/backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json similarity index 60% rename from backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json rename to backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json index 3d61a47eb1..f631078e76 100644 --- a/backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json +++ b/backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2", + "query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2\n AND NOT COALESCE(\n flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}',\n false\n )", "describe": { "columns": [], "parameters": { @@ -12,5 +12,5 @@ }, "nullable": [] }, - "hash": "5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf" + "hash": "cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b" } diff --git a/backend/windmill-queue/src/flow_status.rs b/backend/windmill-queue/src/flow_status.rs index e6b64c659d..55d2f6ec90 100644 --- a/backend/windmill-queue/src/flow_status.rs +++ b/backend/windmill-queue/src/flow_status.rs @@ -15,6 +15,9 @@ pub async fn update_flow_status_in_progress( let step = get_step_of_flow_status(db, flow).await?; match step { Step::Step { idx: step, .. } => { + // Children of a parallel module start concurrently and are tracked in `flow_jobs`, so its + // `job` would only hold whichever child started last. Writing it rewrites the parent's + // whole flow_status under its row lock once per child, serializing large parallel loops. sqlx::query!( "UPDATE v2_job_status SET flow_status = jsonb_set( @@ -22,7 +25,11 @@ pub async fn update_flow_status_in_progress( ARRAY['modules', $3::INTEGER::TEXT, 'type'], to_jsonb('InProgress'::text) ) - WHERE id = $2", + WHERE id = $2 + AND NOT COALESCE( + flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}', + false + )", job_in_progress, flow, step as i32 diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 05ad6c7bb2..1d56c20b38 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -944,6 +944,15 @@ } } + // A running parallel module keeps this flow's own id in `job` (its children are only in + // `flow_jobs`), so passing it through would make the nested viewer watch this flow again. + function innerViewerJobId(mod: FlowStatusModule): string { + if (mod.job && mod.job == job?.id && mod.flow_jobs?.length) { + return mod.flow_jobs[0] + } + return mod.job ?? '' + } + function onJobsLoadedInner(mod: FlowStatusModule, job: Job, force?: boolean): void { let id = mod.id if (id && ((mod.flow_jobs ?? []).length == 0 || force)) { @@ -1783,7 +1792,7 @@ {subflowParentsGlobalModuleStates} {subflowParentsDurationStatuses} {isSelectedBranch} - jobId={mod.job ?? ''} + jobId={innerViewerJobId(mod)} {reducedPolling} innerModule={mod.flow_jobs ? job.raw_flow?.modules[i]?.value : undefined} flowJobIds={mod.flow_jobs From 4b09558e13fadca6fe604c231a5cdfb896d4d0f8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 12:43:46 +0200 Subject: [PATCH 06/51] feat: start a deferred queued job now without changing its id (#11347) * feat: start a deferred queued job now without changing its id Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse starting a schedule's upcoming tick early Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hide run now on upcoming schedule ticks and register its audit op Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...751e1317e7398dfaf1dfde5c57169cc146c9d.json | 23 ++++ ...b30d71c74fffbba3f5ac6197c985fd73e5c11.json | 23 ++++ ...61af624ab6de67a5c82967f4df8117fe50bc7.json | 47 ++++++++ backend/tests/jobs_read_auth.rs | 93 ++++++++++++++- backend/windmill-api/openapi.yaml | 24 ++++ backend/windmill-api/src/jobs.rs | 112 +++++++++++++++++- .../auditLogs/AuditLogsFilters.svelte | 1 + .../(root)/(logged)/run/[...run]/+page.svelte | 36 +++++- 8 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json create mode 100644 backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json create mode 100644 backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json diff --git a/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json b/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json new file mode 100644 index 0000000000..46b7d2509c --- /dev/null +++ b/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue q SET scheduled_for = now()\n FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev\n WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL\n AND q.scheduled_for > now()\n RETURNING prev.scheduled_for", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "scheduled_for", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d" +} diff --git a/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json b/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json new file mode 100644 index 0000000000..f5a2ac20ed --- /dev/null +++ b/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11" +} diff --git a/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json b/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json new file mode 100644 index 0000000000..4ed2afc15b --- /dev/null +++ b/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone\n FROM v2_job j\n JOIN v2_job_queue q USING (id)\n JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger\n WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule'\n AND j.parent_job IS NULL AND q.scheduled_for > now()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "schedule", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "cron_version", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "timezone", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7" +} diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index c8c154c250..8c5bfee0eb 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -516,10 +516,14 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul Some("SECRET_TOKEN_2"), ) .await; - assert!(status.is_success(), "owner must mint a resume secret: {secret}"); + assert!( + status.is_success(), + "owner must mint a resume secret: {secret}" + ); let secret = secret.trim().trim_matches('"').to_string(); - let approval_result = - format!("completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}"); + let approval_result = format!( + "completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}" + ); let (status, body) = get(&base, &approval_result, None).await; assert!( status.is_success(), @@ -991,6 +995,89 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul !status.is_success(), "a public token must not let an anonymous caller cancel the run (got {status}): {body}" ); + // ---- RUN_NOW is gated like cancel, and only moves a job still waiting for a + // future start. ---- + let run_now = format!("queue/run_now/{RUNNING_JOB}"); + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_3")).await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "viewer must not start another user's job early (got {status}): {body}" + ); + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "a running job has no future start to move (got {status}): {body}" + ); + sqlx::query( + "UPDATE v2_job_queue SET running = false, scheduled_for = now() + interval '1 hour' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert!( + status.is_success(), + "owner must start their deferred job now (got {status}): {body}" + ); + let due: bool = + sqlx::query_scalar("SELECT scheduled_for <= now() FROM v2_job_queue WHERE id = $1::uuid") + .bind(RUNNING_JOB) + .fetch_one(&db) + .await?; + assert!(due, "run_now must make the job due immediately"); + + // A schedule tick that is not due yet is refused: the schedule would queue that same + // tick again on completion and run twice. + sqlx::query( + "INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, permissioned_as) + VALUES ('test-workspace', 'u/test-user-2/daily', 'test-user-2', '0 0 3 * * *', + 'u/test-user-2/running_secret', 'u/test-user-2')", + ) + .execute(&db) + .await?; + sqlx::query( + "UPDATE v2_job SET trigger_kind = 'schedule', trigger = 'u/test-user-2/daily' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + sqlx::query( + "UPDATE v2_job_queue SET scheduled_for = + (date_trunc('day', now() AT TIME ZONE 'UTC') + interval '1 day 3 hours') AT TIME ZONE 'UTC' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "an upcoming schedule tick must not be started early (got {status}): {body}" + ); + // A tick a concurrency limit pushed past its time is off the cron occurrences and + // may still be started early. + sqlx::query( + "UPDATE v2_job_queue SET scheduled_for = now() + interval '1 hour 37 seconds 123 milliseconds' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert!( + status.is_success(), + "a deferred schedule tick must still start now (got {status}): {body}" + ); + sqlx::query("UPDATE v2_job SET trigger_kind = NULL, trigger = NULL WHERE id = $1::uuid") + .bind(RUNNING_JOB) + .execute(&db) + .await?; + // The owner still cancels their own job (no over-blocking). Keep this last: it // takes RUNNING_JOB out of the queue. let (status, body) = post( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4346dfcd0a..21ca421af3 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -16310,6 +16310,29 @@ paths: items: type: string + /w/{workspace}/jobs/queue/run_now/{id}: + post: + summary: start a queued job scheduled for later now + description: | + Moves the start of a queued job that is waiting for a future time (scheduled, debounced, + re-scheduled by a concurrency limit, or a flow step waiting on a sleep or a retry delay) + to now, keeping the same job id. A job with a + concurrency limit is still checked against its limit when it is pulled. A schedule's + upcoming tick is refused, since the schedule would queue that tick again. + operationId: runQueuedJobNow + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: job id + content: + text/plain: + schema: + type: string + /w/{workspace}/jobs/queue/cancel_selection: post: summary: cancel jobs based on the given uuids @@ -31177,6 +31200,7 @@ components: - "jobs" - "jobs.cancel" - "jobs.force_cancel" + - "jobs.run_now" - "jobs.disapproval" - "jobs.delete" - "account.delete" diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 052c9df4bb..b9e71271d4 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -116,7 +116,9 @@ use windmill_common::{ query_builders, scripts::{ScriptHash, ScriptLang}, users::username_to_permissioned_as, - utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath}, + utils::{ + not_found_if_none, now_from_db, paginate, require_admin, Pagination, ScheduleType, StripPath, + }, }; use windmill_common::{ @@ -304,6 +306,7 @@ pub fn workspaced_service() -> Router { .route("/queue/position/{timestamp}", get(get_queue_position)) .route("/queue/scheduled_for/{id}", get(get_scheduled_for)) .route("/queue/cancel_selection", post(cancel_selection)) + .route("/queue/run_now/{id}", post(run_queued_job_now)) .route("/completed/count", get(count_completed_jobs)) .route("/completed/count_jobs", get(count_completed_jobs_detail)) .route( @@ -835,6 +838,113 @@ async fn force_cancel( } } +/// Moves a queued job's `scheduled_for` to now so the next pull picks it up, keeping its id. +/// A job with a concurrency limit still goes through the usual check at pull time and is +/// re-scheduled again if its key is still saturated. +async fn run_queued_job_now( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> error::Result { + require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?; + refuse_upcoming_schedule_tick(&db, &w_id, id).await?; + + let mut tx = db.begin().await?; + let previous = sqlx::query_scalar!( + "UPDATE v2_job_queue q SET scheduled_for = now() + FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev + WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL + AND q.scheduled_for > now() + RETURNING prev.scheduled_for", + id, + w_id, + ) + .fetch_optional(&mut *tx) + .await?; + + let Some(previous) = previous else { + tx.commit().await?; + return Err( + if sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)", + id, + w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false) + { + Error::BadRequest(format!( + "job {id} is not waiting for a future start: it is running, suspended, \ + canceled or already due" + )) + } else { + Error::NotFound(format!("queued job id {id} does not exist")) + }, + ); + }; + + audit_log( + &mut *tx, + &authed, + "jobs.run_now", + ActionKind::Update, + &w_id, + Some(&id.to_string()), + Some([("previous_scheduled_for", previous.to_rfc3339().as_str())].into()), + ) + .await?; + tx.commit().await?; + + windmill_queue::append_logs( + &id, + &w_id, + format!( + "\nStart moved from {previous} to now by {}\n", + authed.display_username() + ), + &db.clone().into(), + ) + .await; + + Ok(id.to_string()) +} + +/// A schedule queues its next tick when the current one completes, computed from the +/// completion time. Starting a tick that is not yet due would therefore queue that same +/// tick again and run the schedule twice. A tick pushed past its time by a concurrency +/// limit no longer sits on a cron occurrence, so it may still be started early. +async fn refuse_upcoming_schedule_tick(db: &DB, w_id: &str, id: Uuid) -> error::Result<()> { + let Some(tick) = sqlx::query!( + "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone + FROM v2_job j + JOIN v2_job_queue q USING (id) + JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger + WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule' + AND j.parent_job IS NULL AND q.scheduled_for > now()", + id, + w_id, + ) + .fetch_optional(db) + .await? + else { + return Ok(()); + }; + let sched = ScheduleType::from_str(&tick.schedule, tick.cron_version.as_deref(), false)?; + let tz = + chrono_tz::Tz::from_str(&tick.timezone).map_err(|e| Error::BadRequest(e.to_string()))?; + let just_before = (tick.scheduled_for - chrono::Duration::milliseconds(1)).with_timezone(&tz); + if sched.find_next(&just_before)? == tick.scheduled_for { + return Err(Error::BadRequest(format!( + "job {id} is the upcoming tick of schedule {}; run the schedule's script or flow \ + directly instead", + tick.path + ))); + } + Ok(()) +} + #[derive(Serialize)] struct QueuePosition { position: Option, diff --git a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte index 96d341dc72..4090a39dbb 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte @@ -149,6 +149,7 @@ JOBS: 'jobs', JOBS_CANCEL: 'jobs.cancel', JOBS_FORCE_CANCEL: 'jobs.force_cancel', + JOBS_RUN_NOW: 'jobs.run_now', JOBS_DISAPPROVAL: 'jobs.disapproval', JOBS_DELETE: 'jobs.delete', JOBS_SHARE_PUBLICLY: 'jobs.share_publicly', diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 932a76ae94..5fc7c1209d 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -40,8 +40,10 @@ EllipsisVertical, Share2, Globe, - Users + Users, + Play } from 'lucide-svelte' + import { forLater } from '$lib/forLater' import { isJobResolvable } from '$lib/utils' import { @@ -280,6 +282,27 @@ } } + // A schedule's upcoming tick sits on a whole second and the backend refuses it; a tick + // deferred by a concurrency limit lands on a sub-second instant and can still start now. + let canRunNow = $derived( + job?.type === 'QueuedJob' && + !job.running && + !job.suspend && + !!job.scheduled_for && + forLater(job.scheduled_for) && + !(job.schedule_path && new Date(job.scheduled_for).getMilliseconds() === 0) + ) + + async function runJobNow(id: string) { + try { + await JobService.runQueuedJobNow({ workspace: $workspaceStore!, id }) + sendUserToast(`job ${id} will start as soon as a worker is available`) + getJob() + } catch (err) { + sendUserToast(`could not start job now: ${err?.body ?? err}`, true) + } + } + // Initialize view tab to logs since result is now outside tabs function initView(): void { // Result is now displayed outside tabs, so always default to logs @@ -886,6 +909,17 @@ Current runs {/if} + {#if canRunNow} + + {/if} {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} {#if !forceCancel} + {#if !!actingUser?.is_admin || !!$superadmin} + + {/if}
{/if} - {#each treeRoots as root (root.datatable ?? '')} - {@const dtOpen = isExpanded(root.datatable)} - {#if root.datatable !== undefined} - {@const hasMenu = !multiSelectMode && onDatatableAction !== undefined} - {@const roleInfo = roleOf(root.datatable)} - - {/if} - {#if dtOpen} - {#if root.error} -

{root.error}

{/if} - {#each root.schemas as sc (sc.schemaKey)} - {@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)} - {@const indent = root.datatable !== undefined ? 'pl-7' : 'pl-3'} - {#if dbSupportsSchemas} + {#each treeRoots as root (root.datatable ?? '')} + {@const dtOpen = isExpanded(root.datatable)} + {#if root.datatable !== undefined} + {@const hasMenu = !multiSelectMode && onDatatableAction !== undefined} + {@const roleInfo = roleOf(root.datatable)} {/if} - - - {#if schemaOpen || !dbSupportsSchemas} - {@const tableIndent = dbSupportsSchemas - ? root.datatable !== undefined - ? 'pl-11' - : 'pl-7' - : root.datatable !== undefined - ? 'pl-7' - : 'pl-3'} - {#each sc.tables as tableKey (tableKey)} - {@const entry = { - datatable: root.datatable, - schema: sc.schemaKey, - table: tableKey - }} - {@const hasMenu = !multiSelectMode} - {@const isSelected = - root.datatable === currentDatatable && - selected.schemaKey === sc.schemaKey && - selected.tableKey === tableKey} + {#if dtOpen} + {#if root.error} +

{root.error}

+ {/if} + {#each root.schemas as sc (sc.schemaKey)} + {@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)} + {@const indent = root.datatable !== undefined ? 'pl-7' : 'pl-3'} + {#if dbSupportsSchemas} - {/each} - {#if canCreateTableIn(root.datatable, sc.schemaKey)} - {/if} + + + {#if schemaOpen || !dbSupportsSchemas} + {@const tableIndent = dbSupportsSchemas + ? root.datatable !== undefined + ? 'pl-11' + : 'pl-7' + : root.datatable !== undefined + ? 'pl-7' + : 'pl-3'} + {#each sc.tables as tableKey (tableKey)} + {@const entry = { + datatable: root.datatable, + schema: sc.schemaKey, + table: tableKey + }} + {@const hasMenu = !multiSelectMode} + {@const isSelected = + root.datatable === currentDatatable && + selected.schemaKey === sc.schemaKey && + selected.tableKey === tableKey} + + {/each} + {#if canCreateTableIn(root.datatable, sc.schemaKey)} + + {/if} + {/if} + + {/each} + {#if dbSupportsSchemas && search.trim() === '' && canCreateSchemaIn(root.datatable)} + {/if} -
- {/each} - {#if dbSupportsSchemas && search.trim() === '' && canCreateSchemaIn(root.datatable)} - - {/if} - {/if} - {/each} - - - - {#if mainPane} - {@render mainPane()} - {:else if viewMode === 'diagram'} - { - onViewMode?.('data') - selectTable(currentDatatable, schema, table) - }} - /> - {:else if tableKey && colDefs?.[tableKey]?.length} - {@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey, whereClause })} - (rowFilter = undefined)} - bind:this={_dbTable} - /> - {:else if databaseIsEmpty} -
- Database is empty - {#if onImport} -
- - {#if !!actingUser?.is_admin || !!$superadmin} - {/if} -
+ {/each} +
+
+ + + {#if mainPane} + {@render mainPane()} + {:else if tabs} + + {#each tabs.tabs as tab (tab.id)} + {#if mountedTabs.has(tab.id)} +
+ {#if tab.kind === 'data'} + {@render dataView( + tab.id, + tab.schema ?? ownSelected.schemaKey, + tab.table, + tab.id === tabs.currentData?.id + )} + {:else if tab.kind === 'diagram'} + {@render diagramView(tab.tables ?? [])} + {:else} + {@render sqlTab?.(tab)} + {/if} +
+ {/if} + {/each} + {:else if viewMode === 'diagram'} + {@render diagramView(diagramTables)} + {:else} + {@render dataView('', selected.schemaKey, selected.tableKey, true)} {/if} - - {/if} -
- + + + {/if} + (aclDrawer = undefined)}> diff --git a/frontend/src/lib/components/DBManagerContent.svelte b/frontend/src/lib/components/DBManagerContent.svelte index b88b1d1510..8894043688 100644 --- a/frontend/src/lib/components/DBManagerContent.svelte +++ b/frontend/src/lib/components/DBManagerContent.svelte @@ -1,4 +1,8 @@ tabs?.update(tab.id, { code })} + onSchemaChange={() => refresh()} + schema={shownSchema} + /> + {/snippet} - - -
{ - // Only proceed if the click is directly on this div and not on the child elements - if (e.target === e.currentTarget) { - replResultData = undefined - } - }} - > - {#if replResultData} - {#key replResultData} - - {/key} - {/if} -
+ {#if !tabbed} + + +
{ + // Only proceed if the click is directly on this div and not on the child elements + if (e.target === e.currentTarget) { + replResultData = undefined + } + }} + > + {#if replResultData} + {#key replResultData} + + {/key} + {/if} +
+ {/if} + dbTableOpsFactory={({ colDefs, tableKey }) => dbTableOpsWithPreviewScripts({ colDefs, tableKey, - whereClause, input: _input, workspace: ws, tag: workerTag @@ -373,9 +429,10 @@ bind:this={_dbManager} {disabledTables} features={getDbFeatures(input)} + tableLayoutKey={(schema, table) => dbTableLayoutStorageKey(ws, _input, schema, table)} />
- {#if showRepl} + {#if showRepl && !tabbed} refresh()} - placeholderTableName={sortArray( - Object.keys( - shownSchema.schema[ - 'public' in shownSchema.schema - ? 'public' - : 'dbo' in shownSchema.schema - ? 'dbo' - : Object.keys(shownSchema.schema ?? {})?.[0] - ] ?? {} - ) - )?.[0]} + {placeholderTableName} + schema={shownSchema} /> {/if} diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index 2d4060a59c..bd016544b0 100644 --- a/frontend/src/lib/components/DBManagerDrawer.svelte +++ b/frontend/src/lib/components/DBManagerDrawer.svelte @@ -8,21 +8,25 @@ import Select from './select/Select.svelte' import { ArrowLeft, + Code, Copy, Download, - Expand, - Minimize, Network, + Plus, RefreshCcw, Table2, - Upload + Tag, + Upload, + X } from 'lucide-svelte' import DBManagerContent from './DBManagerContent.svelte' - import type { DbManagerViewMode, PendingRowAction } from './DBManager.svelte' - import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' - import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' + import DropdownV2 from './DropdownV2.svelte' + import { flip } from 'svelte/animate' + import type { PendingRowAction } from './DBManager.svelte' + import type { DbManagerTab, DbManagerTabKind } from './dbManagerTabs.svelte' import { logFeatureUsage } from '$lib/utils/featureUsage' import { getDbType } from './dbOps' + import { renderDbQuotedIdentifier } from './apps/components/display/dbtable/utils' import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte' import DataTablePermissionsButton from './workspaceSettings/DataTablePermissionsButton.svelte' import { resource } from 'runed' @@ -176,12 +180,8 @@ dbManagerContent?.clearReplResult() } - let windowWidth = $state(window.innerWidth) - let expand = $state(false) - $effect(() => { if (!open) { - expand = false uriState.closeDrawer() // An action asked for on one data table must not be waiting when the // drawer is next opened on another database — or on no data table at @@ -200,20 +200,55 @@ let hasReplResult = $state(false) - // Which view the manager shows. Held here, beside the control that switches it - // and outside the key that remounts the manager, so picking another data table - // or role stays on the view the user was reading. - let requestedViewMode = $state('data') - // Only PostgreSQL has a diagram; the manager clamps the mode itself, and this - // keeps the control off the header for a database that cannot show one. + // The tabs start where the right pane does, so a narrow tree pane would run them into the + // title: below the room the title needs, in pixels, it goes. + const TITLE_ROOM_PX = 200 + let showTitle = $derived.by(() => { + const left = dbManagerContent?.dbManager()?.mainPaneLeft() + return !dbManagerContent?.tabsModel() || !left || left >= TITLE_ROOM_PX + }) + + // Only PostgreSQL has a diagram. let diagramSupported = $derived( !!uriState.effectiveInput && getDbType(uriState.effectiveInput) === 'postgresql' ) + const TAB_KINDS: { kind: DbManagerTabKind; label: string; icon: typeof Table2 }[] = [ + { kind: 'data', label: 'Data', icon: Table2 }, + { kind: 'diagram', label: 'Diagram', icon: Network }, + { kind: 'sql', label: 'SQL Editor', icon: Code } + ] + /** A plain mouse wheel scrolls vertically, which the tab strip cannot: turn it sideways. + * Registered by hand because Svelte's `onwheel` is passive and cannot prevent the default. */ + function wheelScrollsSideways(el: HTMLElement) { + const onWheel = (e: WheelEvent) => { + if (e.deltaX || !e.deltaY || el.scrollWidth <= el.clientWidth) return + el.scrollLeft += e.deltaY + e.preventDefault() + } + el.addEventListener('wheel', onWheel, { passive: false }) + return () => el.removeEventListener('wheel', onWheel) + } + + function quotedTableName(schema: string | undefined, table: string): string { + const input = uriState.effectiveInput + if (!input) return table + const q = (name: string) => renderDbQuotedIdentifier(name, getDbType(input)) + return schema ? `${q(schema)}.${q(table)}` : q(table) + } + + let draggedTabId: string | undefined = $state() + let dropTarget: { id: string; side: 'before' | 'after' } | undefined = $state() + function tabLabel(tab: DbManagerTab): string { + if (tab.kind === 'data') return tab.table ?? 'Data' + return TAB_KINDS.find((k) => k.kind === tab.kind)!.label + } + // Export/Import state let exportDrawerOpen = $state(false) let exportResult = $state('') let importDrawerOpen = $state(false) + let workerTagOpen = $state(false) let importLoading = $state(false) let importSource = $state(undefined) /** Which database an import writes into; set when driven from a tree row. */ @@ -318,17 +353,9 @@ } - - - + { if (hasReplResult) { dbManagerContent?.clearReplResult() @@ -338,14 +365,16 @@ }} CloseIcon={hasReplResult ? ArrowLeft : undefined} noPadding + overflow_y={false} + fullScreen={false} + titleClass="text-sm text-primary" id="db-manager-drawer" > {#if contentInput && ws && roleSettled} {#key `${selectedDatatable}~${selectedRole ?? ''}`} (requestedViewMode = mode)} + tabbed input={contentInput} workspace={uriState.workspace} datatableTree={uriState.isDatatableInput ? datatables.current : undefined} @@ -357,9 +386,7 @@ uriState.selectedRole = role }} bind:pendingAction - canManageDatatable={!!($superadmin || $userStore?.is_admin) && - !!$enterpriseLicense && - !isCloudHosted()} + canManageDatatable={!!($superadmin || $userStore?.is_admin)} onDatatableAction={runDatatableAction} bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)} bind:hasReplResult @@ -375,38 +402,167 @@ > {/key} {/if} + {#snippet titleExtra()} + {@const mainPaneLeft = dbManagerContent?.dbManager()?.mainPaneLeft()} + {@const tabs = dbManagerContent?.tabsModel()} + + {#if tabs && mainPaneLeft} + +
+ {#each tabs?.tabs ?? [] as tab (tab.id)} + {@const active = tab.id === tabs?.activeId} + {@const Icon = TAB_KINDS.find((k) => k.kind === tab.kind)!.icon} + {@const dropSide = dropTarget?.id === tab.id ? dropTarget.side : undefined} + + +
{ + draggedTabId = tab.id + e.dataTransfer?.setData('text/plain', tab.id) + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move' + }} + ondragover={(e) => { + if (!draggedTabId) return + e.preventDefault() + if (draggedTabId === tab.id) { + dropTarget = undefined + return + } + const rect = e.currentTarget.getBoundingClientRect() + const side = e.clientX < rect.left + rect.width / 2 ? 'before' : 'after' + if (dropTarget?.id !== tab.id || dropTarget.side !== side) + dropTarget = { id: tab.id, side } + }} + ondrop={(e) => { + e.preventDefault() + if (draggedTabId && dropTarget) + tabs?.move(draggedTabId, dropTarget.id, dropTarget.side) + draggedTabId = undefined + dropTarget = undefined + }} + ondragend={() => { + draggedTabId = undefined + dropTarget = undefined + }} + {@attach (el) => { + if (active) el.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }} + > + {#if dropSide} +
+ {/if} + + +
+ {/each} + k.kind !== 'diagram' || diagramSupported).map((k) => ({ + displayName: k.label, + icon: k.icon, + action: () => { + // A query opened from a data tab starts on that tab's table. + const from = tabs?.active.kind === 'data' ? tabs.active : undefined + tabs?.add( + k.kind, + k.kind === 'sql' && from?.table + ? { + code: `SELECT * FROM ${quotedTableName(from.schema, from.table)}` + } + : {} + ) + logFeatureUsage('db_manager', 'view_mode', { key: k.kind }) + } + }))} + btnId="db-manager-new-tab" + > + {#snippet buttonReplacement()} +
+ +
+ {/snippet} +
+
+ {/if} + {/snippet} {#snippet actions()} - - {#if enableImportExport && !uriState.isDatatableInput} - - - {/if} - {#if diagramSupported} - logFeatureUsage('db_manager', 'view_mode', { key: v })} - > - {#snippet children({ item })} - - - {/snippet} - - {/if} {#if uriState.effectiveInput && ws} - workerTag.tag, (v) => (workerTag.tag = v)} - input={uriState.effectiveInput} - workspace={ws} - /> + {@const input = uriState.effectiveInput} + +
+ [ + { + displayName: workerTag.tag ? `Worker tag: ${workerTag.tag}` : 'Worker tag', + icon: Tag, + action: () => (workerTagOpen = true) + }, + // A data table exports and imports from its row menu in the tree; a + // plain database has no tree row to hold them. + ...(enableImportExport && !uriState.isDatatableInput + ? [ + { + displayName: 'Export database', + icon: Download, + action: () => handleExportSchema() + }, + { + displayName: 'Import database', + icon: Upload, + action: () => ((importTarget = undefined), (importDrawerOpen = true)) + } + ] + : []) + ]} + btnId="db-manager-database-actions" + /> + workerTag.tag, (v) => (workerTag.tag = v)} + bind:open={workerTagOpen} + hideTrigger + {input} + workspace={ws} + /> +
{/if} - + + {/snippet} + + + {/if} + {@render hiddenColumnsNote()} +{/snippet} + +{#snippet addColumnCell()} + {#if canAddColumn} +
+ {/if} + {#if hidden.length} +
+ {/if} +{/snippet} + + { + if (editing) cancelEdit() + closeFormatOnOutsideClick(e) + }} + onkeydown={(e) => { + if (formatting && e.key === 'Escape') formatting = undefined + }} +/>
-
-
- + +
+ {#if totalRows > 0 && endRow !== 0} + + {Math.min(firstVisible + 1, totalRows)}–{Math.min(lastVisible, totalRows)} + {#if rowCount !== undefined || endRow !== undefined} + of {endRow ?? rowCount} rows + {/if} + + {/if} + + {/snippet} + {#snippet content()} +
+ +
+ {#each columns as col (col.field)} +
+ + {#if formats[col.field]} + + {/if} + openFormat(col.field) + } + ]} + size="2xs" + class="opacity-0 group-hover:opacity-100 focus-within:opacity-100" + /> +
+ {/each} +
+ {#if onNewColumn} +
+ +
+ {/if} +
+ {/snippet} + +
+ {#if dbTableOps.onInsert} + { + if (!$operatingWorkspace) return + dbTableOps.onInsert?.({ values }).then(() => { + refresh() + sendUserToast('Row inserted') + }) + }} + /> {/if}
- {#if dbTableOps.onInsert} - { - if (!$operatingWorkspace) return - dbTableOps.onInsert?.({ values }).then((result) => { - refresh?.() - sendUserToast('Row inserted') - }) - }} - /> - {/if}
-
- + +
{ - if ((e.ctrlKey || e.metaKey) && e.key === 'c') { - const selectedCell = api?.getFocusedCell() - if (selectedCell) { - const rowIndex = selectedCell.rowIndex - const colId = selectedCell.column?.getId() - const rowNode = api?.getDisplayedRowAtIndex(rowIndex) - const selectedValue = rowNode?.data?.[colId] - navigator.clipboard.writeText(selectedValue) - sendUserToast('Copied cell value to clipboard', false) + bind:this={scrollEl} + bind:clientHeight={viewportHeight} + bind:clientWidth={viewportWidth} + bind:offsetHeight={gridOuterHeight} + bind:offsetWidth={gridOuterWidth} + class={twMerge( + 'h-full w-full overflow-x-auto overflow-y-scroll relative outline-none text-xs text-primary', + // Wider than the app's default so it is easy to grab. + '[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar-thumb]:rounded-full' + )} + tabindex="0" + role="grid" + aria-rowcount={totalRows} + data-testid="db-table-grid" + onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)} + onkeydown={onGridKeyDown} + onclick={(e) => (selected = cellFromEvent(e))} + ondblclick={(e) => { + const cell = cellFromEvent(e) + if (cell) startEdit(cell.row, cell.column) + }} + > +
+ +
+ {#each displayColumns as col (col.field)} + {@const sorted = sort?.column === col.field ? sort : undefined} + {@const pin = pinned[col.field]} + {#if col.field === firstRightPinned} + {@render addColumnHeader()} +
+ {/if} +
+ + +
startResize(e, col.field)} + ondblclick={(e) => { + e.stopPropagation() + autoSize([col.field]) + }} + >
+
+ {/each} + {#if !firstRightPinned} + {@render addColumnHeader()} +
+ {/if} +
+ + {#if endRow === 0} +
+ {hasFilters ? 'No rows match the filters' : 'No rows'} +
+ {/if} + +
+ {#each renderedIndices as i (i)} + {@const data = rowAt(i)} +
+ {#each displayColumns as col (col.field)} + {@const isSelected = selected?.row === i && selected.column === col.field} + {@const pin = pinned[col.field]} + {@const style = + data && data !== 'loading' + ? cellStyle(data[col.field], col.datatype, formats[col.field]) + : undefined} + {#if col.field === firstRightPinned} + {@render addColumnCell()} +
+ {/if} +
+ {#if pin} + +
+ {/if} + {#if data === 'loading'} +
+ {:else if data} + {@const value = data[col.field]} + {@const format = formats[col.field]} + {@const shown = formatValue(value, format) ?? formatCell(value)} + {#if value === null || value === undefined} + NULL + {:else if fkByColumn[col.field] && hasForeignKeyValue(value)} + {@const fk = fkByColumn[col.field]} + + {:else} + {shown} + {/if} + {/if} +
+ {/each} + {#if !firstRightPinned} + {@render addColumnCell()} +
+ {/if} +
+ {/each} +
+
+
+
+ + {#if gridOuterWidth - viewportWidth > 0} +
+ {/if} + {#if gridOuterHeight - viewportHeight > 0} +
+ {/if} +
+
+ + { + if (editing) { + const el = cellElement(editing.row, editing.column) + if (el) editorRect = el.getBoundingClientRect() + } + return editorRect + }} +> + +
e.stopPropagation()}> + {#if editing} + { + if (e.key === 'Enter') { + e.preventDefault() + commitEdit() + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + cancelEdit() } } }} - >
-
- -
-
- - {#snippet text()} - Download - {/snippet} -
- {#if rowCount} - {firstRow}{'->'}{lastRow + 1} of {rowCount} rows - {:else} - {firstRow}{'->'}{lastRow + 1} - {/if} -
+ /> +
Enter to save, Esc to cancel
+ {/if} - + + + { + if (formatting) { + const header = scrollEl?.querySelector(`[data-header-column="${CSS.escape(formatting)}"]`) + // A hidden column has no header: its format opens from the columns button. + const el = header ?? columnsAnchor + if (el) formatRect = el.getBoundingClientRect() + } + return formatRect + }} +> +
+ {#if formatting} + {@const column = formatting} + setFormat(column, f)} + /> + {/if} +
+
diff --git a/frontend/src/lib/components/DbColumnFormatEditor.svelte b/frontend/src/lib/components/DbColumnFormatEditor.svelte new file mode 100644 index 0000000000..36f98574a1 --- /dev/null +++ b/frontend/src/lib/components/DbColumnFormatEditor.svelte @@ -0,0 +1,362 @@ + + +{#snippet sample(style: { bg?: string; text?: string; bold?: boolean; italic?: boolean })} + + Aa + +{/snippet} + +{#snippet presets(onPick: (preset: (typeof RULE_PRESETS)[number]) => void)} +
+ {#each RULE_PRESETS as preset, i (i)} + + {/each} +
+{/snippet} + +
+
+
+ Format {column} + Only changes how values show in this view. +
+ +
+ +
+ Unit +
+ ({ label: String(i), value: i })) + ]} + bind:value={ + () => format?.decimals ?? AUTO_DECIMALS, + (v) => update({ decimals: v === AUTO_DECIMALS ? undefined : v }) + } + /> + format?.notation ?? 'plain', + (v) => update({ notation: v === 'plain' ? undefined : (v as 'thousands' | 'compact') }) + } + > + {#snippet children({ item })} + + + + {/snippet} + +
+ + 1234567.891 shows as {formatValue(1234567.891, format) ?? '1234567.891'} + +
+ +
+ Alignment + format?.align ?? 'auto', + (v) => update({ align: v === 'auto' ? undefined : (v as 'left' | 'right') }) + } + > + {#snippet children({ item })} + + + + {/snippet} + +
+ +
+ Color rules + {#each rules as rule, i (i)} + +
{ + if (dragFrom === undefined) return + e.preventDefault() + const r = e.currentTarget.getBoundingClientRect() + dropAt = { index: i, side: e.clientY < r.top + r.height / 2 ? 'above' : 'below' } + }} + ondrop={(e) => { + e.preventDefault() + dropRule() + }} + > + {#if dropAt?.index === i && dragFrom !== undefined} +
+ {/if} + +
{ + dragFrom = i + const row = e.currentTarget.parentElement + if (row) e.dataTransfer?.setDragImage(row, 8, row.offsetHeight / 2) + }} + ondragend={() => { + dragFrom = undefined + dropAt = undefined + }} + > + +
+
+ = 4, =paid' }} + bind:value={() => rule.condition, (v) => setRule(i, { condition: v })} + /> +
+ setRule(i, { bg: e.currentTarget.value })} + /> + setRule(i, { text: e.currentTarget.value })} + /> +
+ {/each} +
+ + {#snippet trigger()} + + {/snippet} + {#snippet content()} + {@render presets((preset) => { + update({ rules: [...rules, { condition: '', ...preset }] }) + addRuleOpen = false + })} + {/snippet} + +
+ + Conditions use the search bar's syntax: text contains, =value is exact, numbers take >, + >=, <, <= and !=. An empty condition matches every cell. Every matching rule applies, + a later one overriding an earlier one. + +
+
diff --git a/frontend/src/lib/components/DbForeignKeyTooltip.svelte b/frontend/src/lib/components/DbForeignKeyTooltip.svelte deleted file mode 100644 index 526d6b2b69..0000000000 --- a/frontend/src/lib/components/DbForeignKeyTooltip.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
-
- References {targetTable}.{targetColumn} -
- -
diff --git a/frontend/src/lib/components/DbSqlTab.svelte b/frontend/src/lib/components/DbSqlTab.svelte new file mode 100644 index 0000000000..a22f89c883 --- /dev/null +++ b/frontend/src/lib/components/DbSqlTab.svelte @@ -0,0 +1,63 @@ + + + + + (result = data)} + /> + + + {#if resultOps} + + {#key resultOps} + + {/key} + {:else} +
+ Run a query to see its result here +
+ {/if} +
+
diff --git a/frontend/src/lib/components/DbWorkerTagButton.svelte b/frontend/src/lib/components/DbWorkerTagButton.svelte index 3cbb60dc53..70db48b956 100644 --- a/frontend/src/lib/components/DbWorkerTagButton.svelte +++ b/frontend/src/lib/components/DbWorkerTagButton.svelte @@ -12,24 +12,40 @@ input: DbInput /** Workspace the custom tags are read from; defaults to the navigation one. */ workspace?: string + /** Render no button: the picker is opened through `open`, and pops up where this is placed. */ + hideTrigger?: boolean + open?: boolean } - let { tag = $bindable(), input, workspace = undefined }: Props = $props() + let { + tag = $bindable(), + input, + workspace = undefined, + hideTrigger = false, + open = $bindable() + }: Props = $props() let defaultTag = $derived(getDefaultDbTag(input)) - + open ?? false, (v) => (open = v)} + class={hideTrigger ? 'w-0 h-0 overflow-hidden' : undefined} + triggerAttrs={hideTrigger ? { tabindex: -1, 'aria-hidden': true } : undefined} +> {#snippet trigger()} - + {#if !hideTrigger} + + {/if} {/snippet} {#snippet content()}
diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 6aabc30c2b..5ec3c62b5e 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -141,6 +141,9 @@ awareness?: any | undefined folding?: boolean args?: Record | undefined + /** Schema to complete SQL against when the caller already holds it; otherwise it is + * looked up from `args.database`. */ + sqlSchema?: DBSchema | undefined useWebsockets?: boolean small?: boolean scriptLang: Preview['language'] | 'bunnative' | 'tsx' | 'jsx' | 'json' | undefined @@ -191,6 +194,7 @@ awareness = undefined, folding = false, args = undefined, + sqlSchema = undefined, useWebsockets = true, small = false, scriptLang, @@ -274,7 +278,8 @@ const sessionScopedChatManager = getContext('aiChatManager') // let graphqlService: MonacoGraphQLAPI | undefined = undefined - let dbSchema: DBSchema | undefined = $state(undefined) + let fetchedDbSchema: DBSchema | undefined = $state(undefined) + let dbSchema: DBSchema | undefined = $derived(sqlSchema ?? fetchedDbSchema) let destroyed = false const uri = computeUri( @@ -777,8 +782,8 @@ async function updateSchema(newSchemaRes: string | undefined) { if (typeof newSchemaRes === 'string') { const resourcePath = newSchemaRes.replace('$res:', '') - dbSchema = $dbSchemas[resourcePath] - if (dbSchema === undefined) { + fetchedDbSchema = $dbSchemas[resourcePath] + if (fetchedDbSchema === undefined) { $dbSchemas[resourcePath] = await getDbSchemas( lang === 'graphql' ? 'graphql' : (scriptLang ?? ''), resourcePath, @@ -787,9 +792,9 @@ { customTag } ) } - dbSchema = $dbSchemas[resourcePath] + fetchedDbSchema = $dbSchemas[resourcePath] } else { - dbSchema = undefined + fetchedDbSchema = undefined } } @@ -824,6 +829,9 @@ sqlSchemaCompletor = languages.registerCompletionItemProvider('sql', { triggerCharacters: ['.', ' ', '('], provideCompletionItems: function (model, position) { + // Registered for the whole `sql` language: every other mounted SQL editor + // would otherwise add its own schema's suggestions to this one's. + if (model !== editor?.getModel()) return { suggestions: [] } const textUntilPosition = model.getValueInRange({ startLineNumber: 1, startColumn: 1, diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 3a4833866a..30adb56c7c 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -339,6 +339,14 @@ hideDropdownOnFreeText?: boolean // Notified whenever the dropdown's effective visibility changes onDropdownVisibleChange?: (visible: boolean) => void + size?: 'md' | 'lg' + // On the right the icon gives way to the clear button once there is text; on the left + // it stays put. + searchIconPosition?: 'left' | 'right' + // Caps the suggestions dropdown, which otherwise matches the searchbar's width. + dropdownMaxWidth?: number + // Gap in px between the searchbar and the dropdown below it. + dropdownOffsetY?: number } type SchemaT = FilterSchemaRec // TODO: Generic @@ -351,7 +359,11 @@ autofocus, hideDropdownOnFreeText = false, onDropdownVisibleChange, - inputId + inputId, + size = 'md', + searchIconPosition = 'right', + dropdownMaxWidth, + dropdownOffsetY = 0 }: Props = $props() let _value = new DebouncedTempValue( @@ -702,7 +714,7 @@ class={twMerge( 'flex items-center rounded-md bg-surface-input overflow-clip', inputBorderClass({ error: errors.length > 0, forceFocus: open }), - ButtonType.UnifiedHeightClasses.md, + ButtonType.UnifiedHeightClasses[size], className )} onmousedown={(e) => { @@ -716,6 +728,9 @@ }} bind:this={inputElement} > + {#if searchIconPosition === 'left'} + + {/if} { @@ -752,7 +770,7 @@ /> {#if asText.val} (_value.current = {})} /> - {:else} + {:else if searchIconPosition === 'right'}
@@ -762,7 +780,11 @@ inputElement?.getBoundingClientRect() ?? new DOMRect()} + getInputRect={() => { + const r = inputElement?.getBoundingClientRect() ?? new DOMRect() + const width = dropdownMaxWidth ? Math.min(r.width, dropdownMaxWidth) : r.width + return new DOMRect(r.x, r.y, width, r.height + dropdownOffsetY) + }} innerClass="!max-h-[25rem]" strictWidth > diff --git a/frontend/src/lib/components/SqlRepl.svelte b/frontend/src/lib/components/SqlRepl.svelte index d58ac65898..b5bdcb872a 100644 --- a/frontend/src/lib/components/SqlRepl.svelte +++ b/frontend/src/lib/components/SqlRepl.svelte @@ -1,5 +1,5 @@ - - - {#await import('$lib/components/Editor.svelte')} - - {:then Module} - - {/await} - - - - { - const data = e.detail as (typeof runHistory)[number] - editor?.setCode(data.code) - onData(data.result, data.code) - }} +
+ {#await import('$lib/components/Editor.svelte')} + + {:then Module} + code, (v) => ((code = v), onCodeChange?.(v))} + scriptLang="mysql" + sqlSchema={schema} + class="w-full h-full" + cmdEnterAction={run} /> - - + {/await} + + + {#if historyOpen} +
+
+ Run history +
+
+ { + const data = e.detail as (typeof runHistory)[number] + editor?.setCode(data.code) + onData(data.result, data.code) + }} + /> +
+
+ {:else} +
+
+ {/if} +
{#if datatableName && ws} - import { Drawer, DrawerContent, Button } from '$lib/components/common' + import { Drawer, DrawerContent, Button, ButtonType } from '$lib/components/common' import { CornerDownLeft, Plus } from 'lucide-svelte' import InsertRow from './dbtable/InsertRow.svelte' import type { ColumnDef } from './dbtable/utils' @@ -9,9 +9,11 @@ columnDefs: ColumnDef[] dbType: DbType onInsert: (args: Record) => void | Promise + /** Sizes the trigger on the unified scale; without it the trigger keeps its legacy size. */ + unifiedSize?: ButtonType.UnifiedSize } - let { columnDefs, dbType, onInsert }: Props = $props() + let { columnDefs, dbType, onInsert, unifiedSize }: Props = $props() let args: Record = $state({}) let insertDrawer: Drawer | undefined = $state() @@ -35,7 +37,8 @@ + {#if unavailable} + + + + + {:else} + + {/if} {/if} From 1d119e6b25fd5c72776dfb49917387de4be065c1 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 25 Sep 2026 17:12:28 +0200 Subject: [PATCH 15/51] fix: correct the tool controls and name field of a nested AI agent (#11221) * fix: hide tool controls a nested AI agent cannot use Co-Authored-By: Claude Opus 5 * refactor: drop the tool navigation prop an agent tool now implies Co-Authored-By: Claude Opus 5 * fix: ask for a tool name on every kind of agent tool Co-Authored-By: Claude Opus 5 * fix: keep enabled_tools reachable on a linked nested agent tool Co-Authored-By: Claude Opus 5 * docs: name the tool kinds the header's name field actually renders Co-Authored-By: Claude Opus 5 * docs: state the tool-name rule without listing the kinds it covers Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../flows/common/FlowCardHeader.svelte | 29 +++++++++++-------- .../flows/content/AgentEditorHost.svelte | 1 - .../flows/content/AgentToolWrapper.svelte | 6 +--- .../flows/content/AiAgentStepInputs.svelte | 14 +++++++-- .../flows/content/FlowModuleComponent.svelte | 18 +++++++----- .../flows/map/InsertModuleInner.svelte | 1 + .../flows/pickers/TopLevelNode.svelte | 7 +++-- 7 files changed, 46 insertions(+), 30 deletions(-) diff --git a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte index 5776c88025..6d14463c6b 100644 --- a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte +++ b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte @@ -62,6 +62,10 @@ let toolNameError = $derived( isAgentTool ? getToolNameError(summary ?? '', undefined, siblingToolNames) : undefined ) + // An agent tool's summary is the function name the model is given, not free text, so the field + // asks for a name instead. That holds for every kind a tool can be, which is why the input + // below is one shared snippet rather than one per branch. + let summaryPlaceholder = $derived(isAgentTool ? 'Tool name' : 'Summary') const dispatch = createEventDispatcher() const customUi: FlowBuilderWhitelabelCustomUi | undefined = getContext('customUi') @@ -194,6 +198,14 @@ }) +{#snippet summaryInput()} + +{/snippet} +
{/if} -
- - {#if toolNameError && !isAgentTool} -

{toolNameError}

- {/if} +
+ {@render summaryInput()}
{:else if flowModuleValue.type === 'flow'} flow - + {@render summaryInput()} {:else if flowModuleValue.type === 'aiagent'} AI Agent - + {@render summaryInput()} {/if}
diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index 4f69d3a1a1..f0d65068f0 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -599,7 +599,6 @@ parentModule={agentModule as FlowModule} {enableAi} staticOnly - noToolNavigation forceTestTab={readOnly ? undefined : { [tool.id]: true }} siblingToolNames={tools.filter((t) => t?.id !== tool?.id).map((t) => t?.summary ?? '')} /> diff --git a/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte b/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte index 6ccadc2ee5..69a4acf74e 100644 --- a/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte +++ b/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte @@ -18,8 +18,6 @@ /** See `FlowModuleComponent`: set when the tool belongs to a saved agent rather than to a * step of this flow. */ staticOnly?: boolean - /** See `FlowModuleComponent`: set where there is no graph to select a nested tool on. */ - noToolNavigation?: boolean } let { @@ -31,8 +29,7 @@ forceTestTab, highlightArg, siblingToolNames = undefined, - staticOnly = false, - noToolNavigation = false + staticOnly = false }: Props = $props() @@ -64,7 +61,6 @@ highlightArg={highlightArg?.[tool.id]} isAgentTool={true} {staticOnly} - {noToolNavigation} bind:toolDescription={tool.description} {siblingToolNames} /> diff --git a/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte b/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte index bb0f260a21..e5529dce5c 100644 --- a/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte +++ b/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte @@ -117,6 +117,9 @@ /** A linked agent's memory, once its config has loaded: whether it keeps managed memory decides * which history inputs the step offers. */ linkedMemory?: { memory: unknown } | undefined + /** Set when this agent's config lives in a resource, so `tools` arrives with it rather than + * with the step. */ + agentLinked?: boolean } let { @@ -147,7 +150,8 @@ onAddTool = undefined, onDeleteTool = undefined, toolPickerPortal = undefined, - linkedMemory = undefined + linkedMemory = undefined, + agentLinked = false }: Props = $props() let ws = $derived(workspace ?? $operatingWorkspace) @@ -226,10 +230,16 @@ }) }) + // No editor adds tools to an agent used as a tool, so an empty roster is all an inline one will + // ever have. Not a linked one: its tools arrive with the resource, so empty here means "not + // loaded" as often as "none", and `enabled_tools` is the step's to set either way. + let toolsHidden = $derived(isAgentTool && !agentLinked && tools.length === 0) let scopedFields = $derived( AGENT_FIELDS.filter( (spec) => - agentFieldAppliesTo(spec, schemaProperties) && (!filter || filter.includes(spec.key)) + agentFieldAppliesTo(spec, schemaProperties) && + (!filter || filter.includes(spec.key)) && + !(toolsHidden && spec.group === 'tools') ) ) diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 0c87d6fb9e..a256a9998a 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -128,10 +128,6 @@ staticOnly?: boolean /** Lets the agent's Tools section add a tool through the graph's own insert path. */ flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default - /** Drop the tool roster's drill-in. Selecting a tool means selecting its graph node, so a - * surface without a graph — the agent editor, which addresses one tool at a time — would - * offer a row whose click lands nowhere. */ - noToolNavigation?: boolean toolDescription?: string | undefined siblingToolNames?: string[] } @@ -152,7 +148,6 @@ isAgentTool = false, staticOnly = false, flowModuleSchemaMap = undefined, - noToolNavigation = false, toolDescription = $bindable(undefined), siblingToolNames = undefined }: Props = $props() @@ -206,6 +201,14 @@ let visibleSelected = $derived(selected === 'chat' && !canShowChatTab ? 'inputs' : selected) let runSettings: FlowRunSettings | undefined = $state() let agentLinked = $derived(flowModule.value.type === 'aiagent' && Boolean(flowModule.value.agent)) + // A tool row drills in by selecting that tool's graph node, and the graph draws tool nodes only + // for a step's own agent (`computeAIToolNodes`). An agent used as a tool has none, so its rows + // would offer a click that lands nowhere. + let onSelectToolInGraph = $derived( + isAgentTool + ? undefined + : (toolId: string) => selectionManager.selectId(toolId, { openPanel: true }) + ) let validCode = $state(true) let width = $state(1200) let testJob: Job | undefined = $state(undefined) @@ -1242,15 +1245,14 @@ workspace={opWs} visibilityKey={agentFieldsKey} linkedMemory={agentLinked ? linkedAgentMemory : undefined} + {agentLinked} tools={agentLinked ? getLinkedAgentTools( linkedToolsScope(opWs, $pathStore), linkedToolsModuleId ) : (flowModule.value.tools ?? [])} - onSelectTool={noToolNavigation - ? undefined - : (toolId) => selectionManager.selectId(toolId, { openPanel: true })} + onSelectTool={onSelectToolInGraph} onAddTool={flowModuleSchemaMap ? (detail) => flowModuleSchemaMap?.addToolToAgent(flowModule.id, detail) diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 3c50e213f1..aea3d669ed 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -234,6 +234,7 @@ /> { dispatch('pickAiAgentTool') dispatch('close') diff --git a/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte b/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte index 8cd69e9e18..627b7c2174 100644 --- a/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte +++ b/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte @@ -22,11 +22,14 @@ /** Highlight with the neutral hover surface instead of the accent, for transient * (hover/keyboard) selection rather than the persistent category selection. */ neutral?: boolean + /** Overrides the label's default chevron, for an entry that inserts directly here instead of + * opening a sub-menu. */ + chevron?: boolean onSelect: () => void onHover?: () => void } - let { label, selected, returnIcon, neutral = false, onSelect, onHover }: Props = $props() + let { label, selected, returnIcon, neutral = false, chevron, onSelect, onHover }: Props = $props() interface IconConfig { icon: ComponentType @@ -73,7 +76,7 @@ > {#if config} - {@render iconWithText(config.icon, config.showChevron, config.iconClass ?? '')} + {@render iconWithText(config.icon, chevron ?? config.showChevron, config.iconClass ?? '')} {/if} {#if returnIcon && selected} From 8caf41430123318a73b76e4c202da07aecaa3c1e Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 25 Sep 2026 11:12:47 -0400 Subject: [PATCH 16/51] fix(multiplayer): a malformed frame from one client no longer exits the server (#11353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(multiplayer): don't drop client messages during cold-start token verification `wss.on('connection')` awaits `verifyToken()` before `setupWSConnection()` attaches the 'message' listener. On a cold process that await includes the first `/api/debug/jwks` fetch (~30ms on ECS). A y-websocket client sends sync step 1 the instant the socket opens, and `ws` drops messages emitted with no listener attached, so that step 1 was lost and never answered with step 2 — the client's provider never became `synced`. Buffer messages from the moment the connection is accepted and replay them, in order, once `setupWSConnection()` has installed its handlers. Rejected connections drop the buffer and close with the same 4401/4403 codes as before. Also prefetch the public key at startup when WINDMILL_BASE_URL is set. That is insurance, not the fix: a connection arriving before the prefetch resolves still relies on the buffer. Adds `npm test` in multiplayer/ (node:test, no docker or backend needed) with a fake JWKS endpoint that answers with a delay, which holds the cold window open and makes the race deterministic; wired into the existing test_extra CI job. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(multiplayer): cap what an unauthenticated peer can buffer pre-auth Review follow-up. The pre-auth buffer was unbounded: `ws` sets no `maxPayload` here and the JWKS fetch has no timeout, so a peer that never authenticates could stream frames into memory for as long as `verifyToken` was stalled. Cap it at 32 frames / 1 MiB — a real client only has sync step 1 and its first awareness update in flight there — and close 1009 past that, dropping what was buffered. A socket closed during verification (by the peer, or by that cap) is no longer handed to setupWSConnection: it would be added to `doc.conns` with a 'close' listener that can never fire. The startup prefetch's .catch was dead code — getPublicKey() logs its own failures and resolves to null rather than rejecting. Test helper: pin REQUIRE_SIGNED_MULTIPLAYER_REQUESTS and BASE_INTERNAL_URL so an ambient value cannot turn the rejection tests into false passes; bind the JWKS server on port 0 instead of a released probe port, and retry the spawned server on EADDRINUSE; destroy still-delayed JWKS responses on teardown, since server.close() waits for in-flight requests. Co-Authored-By: Claude Opus 5.5 (1M context) * test(multiplayer): gate the JWKS response instead of delaying it Review follow-up. The cold window was held open by a 1500 ms delay on the fake JWKS response, but that timer started when the startup prefetch reached the fake server, not when the client sent its first frame. A slow enough machine could load the key before the client connected, and the race test would then pass without ever exercising the buffer — a false pass. The fake JWKS server now parks every response until the test calls release(), so the server provably holds no key while the client is sending. The race test releases only after both frames are written to the socket, and asserts the server has not logged the key as loaded at that point; the flood test never releases until after the cap has closed the connection. What is left to wall-clock time is 250 ms for bytes already written to the socket to cross loopback into an otherwise idle server, rather than a window that had to cover process startup, connect and handshake. Also drops the prefetch precondition from the forged-token and flood tests so each test still maps to one behaviour. Suite runs in ~1.1s instead of ~5.3s. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(multiplayer): survive a malformed frame instead of exiting the process `setupWSConnection`'s message handler decoded whatever an authenticated peer put on the wire with no guard: `decoding.readVarUint`, `syncProtocol.readSyncMessage` and `awarenessProtocol.applyAwarenessUpdate` all throw on input they cannot parse, `ws` re-emits a listener's exception on the process, and server.mjs installs no `uncaughtException` handler. One bad frame from one client therefore killed the whole multiplayer server, taking every other document and every other client with it. Catch decode/apply failures, log the document, the client address and the error message (never the payload), and close only the offending connection with 1007 "invalid frame payload data". Frames that arrive once a connection is no longer OPEN are ignored, so the replay of the pre-auth buffer stops at the first refusal instead of applying the rest. docker/entrypoint-extra.sh made that outage permanent: on a service exit it logged a bare PID and then `wait`ed on the rest, so the container stayed up with a dead service and the health checks in front of it — which probe the LSP — saw nothing wrong. It now names the service that died, stops the others through the same shutdown path SIGTERM uses, and exits non-zero so the orchestrator replaces the container. The "no services enabled" branch still sleeps. Tests: multiplayer/test/malformed_frame.test.mjs covers four malformed payloads from an authenticated client and one replayed out of the pre-auth buffer, asserting the 1007 close, a live server process, an undisturbed bystander and a real edit still propagating. docker/test_entrypoint_extra.sh runs the real entrypoint in a container with stub services. Co-Authored-By: Claude Opus 5.5 (1M context) * test(multiplayer): prove the replayed malformed frame really is buffered pre-auth Assert the server has not yet logged the loaded key when the frame is written, and give it the same in-flight margin as the cold-start tests before releasing the JWKS response, so the frame provably goes through the replay path rather than landing on an already-authenticated connection. Co-Authored-By: Claude Opus 5.5 (1M context) * ci(extra): run the entrypoint supervision tests in publish_extra The multiplayer unit tests already run there; the entrypoint test needs only docker and the checkout, so run it in the same job, before the image build. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(multiplayer): handle WebSocket protocol errors and bound the shutdown Two crash paths of the same class as the malformed-frame one, from review. `ws` fails a frame it cannot parse at the protocol level — an unmasked frame from a client, a reserved opcode, a bad RSV bit — inside its Receiver, before the application 'message' handler ever sees it, and `receiverOnError` ends with `websocket.emit('error', err)`. With no 'error' listener that is an unhandled EventEmitter error, so it exited the process just as a malformed payload did. (A raw socket error such as ECONNRESET does not: ws 8.21.3's `socketOnError` swallows those.) Add the listener on the accepted socket, before authentication so the pre-auth window is covered too, and one on the server. Log messages now go through `describeError`, which collapses whitespace and truncates, so nothing that reaches an error message can forge or flood a log line. `stop_services` ended in a bare `wait`. On the `docker stop` path dockerd provides the deadline; the "a service died" path signals itself, so a service that is wedged or slow to honour SIGTERM would hold the container open indefinitely — the state that path exists to prevent. Bound it: SIGTERM, wait SHUTDOWN_GRACE_SECS (10 by default), then SIGKILL the stragglers by name. Tests: multiplayer/test/socket_error.test.mjs (authenticated and pre-auth illegal frames, asserting a live process and continued service), a SIGTERM-ignoring stub scenario in docker/test_entrypoint_extra.sh, and that harness is now bounded throughout — `timeout -k` on foreground runs, a watchdog around the backgrounded ones, and an optional outer timeout on `docker run`. `--entrypoint bash` so the documented windmill-extra:test override runs the harness instead of the image's real entrypoint. The stubs publish a readiness marker and the dying one waits for them, removing a startup race in the harness. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(multiplayer): never log peer bytes, and keep a fatal server error fatal Two review findings on the previous commit, both mine to answer for. `describeError` collapsed whitespace, which is not enough. An error message is not always a fixed string: `applyAwarenessUpdate` runs `JSON.parse` on the peer's bytes and V8 quotes ~30 bytes of the offending input back verbatim, ESC included, so a peer could put terminal escapes and forged content into a log line. Strip everything outside printable ASCII instead, and say so where the comment previously claimed the messages were fixed strings. `wss.on('error')` was worse than the crash it replaced for one case: `ws` forwards the HTTP server's errors there, so a failed listen (EADDRINUSE) was logged and the process then exited 0 — a clean shutdown as far as anything upstream could tell. It now sets a non-zero exit code. Setting `process.exitCode` rather than calling `process.exit()` keeps the log line from being truncated. `openClient` in the test helpers now records the socket error it was already swallowing, so a failed connection reports its cause instead of surfacing as a bare `waitFor` timeout. Tests: a malformed awareness frame whose state is `x\x1b[2J OWNED THE LOG` added to the payload table, with every case now asserting exactly one refusal line and no control characters in it (1 fail before, 0 after, 3 runs); and a server that cannot listen must exit non-zero (1 fail before, 0 after, 3 runs). 14/14 on 5 consecutive runs. Co-Authored-By: Claude Opus 5.5 (1M context) * test(multiplayer): make the exit-status helper robust to spawn and stdio races Review follow-ups on runMultiplayerServerUntilExit. Wait for 'close', not 'exit': 'exit' fires when the child terminates, which can be before its stdio pipes are drained, and the caller reads the output. On the EADDRINUSE path the child writes one line and exits immediately after, which is exactly the shape that loses it. Listen for 'error' too. A child that fails to spawn emits neither 'exit' nor 'close', so the promise would never settle and the SIGKILL guard could not help. Report whether the guard fired, rather than leaving the caller to infer it from the exit signal: `signal` is null for every child exit on Windows, so a server that hung after the listen error would have looked like one that exited on its own. The test asserts on that flag instead. Co-Authored-By: Claude Opus 5.5 (1M context) * test(multiplayer): assert the refusal line itself, and name hasSyncType for what it takes Two review nits on the test helpers. The `doc="..."` assertion searched the whole server log, where CONNECT and DISCONNECT also name the document, so it would have passed even if the refusal stopped naming anything. Every assertion about the refusal is now made against the refusal line, which the test already isolates, and it also checks the peer is named. `hasKind` took a sync sub-type but was named as if it took any message kind, and the two families overlap numerically (`syncStep1 === messageSync === 0`), so a caller passing the wrong one got a silently wrong answer. No runtime check can tell aliased numbers apart, so the fix is the name: `hasSyncType`, with the overlap spelled out where the constants are declared. Co-Authored-By: Claude Opus 5.5 (1M context) * test(multiplayer): make the pre-auth tests prove which path they took The replay test could not establish that its frame went through the pre-auth buffer: a frame delivered after setup, into the live handler, produces the same close code and the same refusal line, so if the in-flight margin were ever missed the test would quietly become a duplicate of the main-loop cases rather than fail. server.mjs now logs REPLAY when, and only when, it replays a buffered pre-auth message — worth having on its own, since that path only runs when a client beat the JWKS fetch on a slow-starting instance — and the test asserts on it. Removing that log line turns the test red, which is the point. The socket-error pre-auth test gated on `jwks.requests >= 1`, which the startup warm-up already satisfies, so it proved nothing about the offender. What makes it the pre-auth case is that the JWKS response stays parked for the whole test; it now asserts the server never logged CONNECT, which is exact. `killedByTimeout` was set before the kill, so a child that exited on its own just before the timeout — with 'close' still pending on the stdio drain, the very window this helper waits for — would have been reported as killed. It now claims the rescue only when there was a live process to signal. 14/14 on eight consecutive runs. Co-Authored-By: Claude Opus 5.5 (1M context) * test(multiplayer): document the frame-recording contract in openClient ws hands every frame over as a Buffer under the default binaryType, text frames included, so recording them as Uint8Array is lossless for both. Worth stating: ws 7 delivered text frames as strings, where new Uint8Array(string) would have been a silent zero-fill, and the difference is not visible at the call site. Co-Authored-By: Claude Opus 5.5 (1M context) * test(multiplayer): pin the close code for an illegal frame, and trim comments to the 4-line rule Both socket-error tests waited for a close and never checked what it was, so an abrupt 1006 teardown would have passed while the comment beside the payload claimed 1002. `ws` sends 1002 for an unmasked frame in both the authenticated and pre-auth cases, confirmed over repeated runs; that is now a named constant asserted in each test, mirroring malformed_frame.test.mjs. Changing the expected value turns both red. The comments added by this branch also ran past the four lines AGENTS.md allows, and several justified the change to a reader rather than stating the invariant. Condensed to the invariant, at the site that would break it. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/publish_extra.yml | 5 + docker/entrypoint-extra.sh | 65 +++++- docker/test_entrypoint_extra.sh | 246 ++++++++++++++++++++++ multiplayer/server.mjs | 73 +++++-- multiplayer/test/cold_start.test.mjs | 65 ++---- multiplayer/test/helpers.mjs | 52 +++++ multiplayer/test/malformed_frame.test.mjs | 231 ++++++++++++++++++++ multiplayer/test/protocol.mjs | 94 +++++++++ multiplayer/test/socket_error.test.mjs | 128 +++++++++++ 9 files changed, 884 insertions(+), 75 deletions(-) create mode 100755 docker/test_entrypoint_extra.sh create mode 100644 multiplayer/test/malformed_frame.test.mjs create mode 100644 multiplayer/test/protocol.mjs create mode 100644 multiplayer/test/socket_error.test.mjs diff --git a/.github/workflows/publish_extra.yml b/.github/workflows/publish_extra.yml index 980eb1da55..863c850266 100644 --- a/.github/workflows/publish_extra.yml +++ b/.github/workflows/publish_extra.yml @@ -48,6 +48,11 @@ jobs: npm ci --omit=dev npm test + # Runs the real entrypoint-extra.sh with stub services, so the supervision + # behaviour is checked without building the image first. + - name: Run entrypoint supervision tests + run: bash docker/test_entrypoint_extra.sh + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/docker/entrypoint-extra.sh b/docker/entrypoint-extra.sh index b32ad2dd94..84b337a8a1 100644 --- a/docker/entrypoint-extra.sh +++ b/docker/entrypoint-extra.sh @@ -4,18 +4,51 @@ set -e # Windmill Extra Services Entrypoint # Starts LSP, Multiplayer, and Debugger services based on environment variables -# Track PIDs for cleanup +# Track PIDs for cleanup, with a parallel array of names so a dead service can be +# named in the logs rather than reported as a bare PID. PIDS=() +NAMES=() -cleanup() { +# How long a service gets to honour SIGTERM before it is killed outright. The +# exit path below signals itself, so nothing else bounds it there; a wedged +# service would otherwise hold the container open half-dead forever. +SHUTDOWN_GRACE_SECS="${SHUTDOWN_GRACE_SECS:-10}" + +stop_services() { echo "[entrypoint] Shutting down services..." for pid in "${PIDS[@]}"; do if kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true fi done + + local i running pid + for ((i = 0; i < SHUTDOWN_GRACE_SECS * 10; i++)); do + running=0 + for pid in "${PIDS[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + running=1 + fi + done + if [ "$running" -eq 0 ]; then + break + fi + sleep 0.1 + done + + for pid in "${PIDS[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + echo "[entrypoint] WARNING: PID $pid did not stop within ${SHUTDOWN_GRACE_SECS}s, killing it" >&2 + kill -9 "$pid" 2>/dev/null || true + fi + done + wait echo "[entrypoint] All services stopped" +} + +cleanup() { + stop_services exit 0 } @@ -116,6 +149,7 @@ if [ "${ENABLE_LSP:-true}" = "true" ]; then cd /pyls PORT=${LSP_PORT:-3001} python3 pyls_launcher.py & PIDS+=($!) + NAMES+=("LSP") echo "[entrypoint] LSP started (PID: ${PIDS[-1]})" fi @@ -125,6 +159,7 @@ if [ "${ENABLE_MULTIPLAYER:-true}" = "true" ]; then cd /multiplayer PORT=${MULTIPLAYER_PORT:-3002} HOST=${HOST:-0.0.0.0} node server.mjs & PIDS+=($!) + NAMES+=("Multiplayer") echo "[entrypoint] Multiplayer started (PID: ${PIDS[-1]})" fi @@ -144,6 +179,7 @@ if [ "${ENABLE_DEBUGGER:-true}" = "true" ]; then bun run dap_debug_service.ts $DEBUGGER_ARGS & PIDS+=($!) + NAMES+=("Debugger") echo "[entrypoint] Debugger started (PID: ${PIDS[-1]})" fi @@ -153,6 +189,7 @@ if [ "${ENABLE_GATEWAY:-true}" = "true" ]; then cd /multiplayer PORT=${GATEWAY_PORT:-3000} node gateway.mjs & PIDS+=($!) + NAMES+=("Gateway") echo "[entrypoint] Gateway started (PID: ${PIDS[-1]})" fi @@ -165,15 +202,27 @@ fi echo "[entrypoint] All enabled services started. Waiting..." -# Wait for any process to exit -wait -n "${PIDS[@]}" 2>/dev/null || true +# Wait for any process to exit. `|| status=$?` also keeps `set -e` from aborting +# here, which would skip the reporting and shutdown below. +status=0 +wait -n "${PIDS[@]}" 2>/dev/null || status=$? -# If one process exits, check which one and report +# Nothing here restarts a dead service, and the health checks in front of this +# container probe a single one, so a dead sibling is invisible and stays dead. +# Name it, stop the rest and exit non-zero, leaving the orchestrator to replace +# the container. for i in "${!PIDS[@]}"; do if ! kill -0 "${PIDS[$i]}" 2>/dev/null; then - echo "[entrypoint] Service (PID: ${PIDS[$i]}) has exited" + echo "[entrypoint] ERROR: ${NAMES[$i]} (PID: ${PIDS[$i]}) has exited" >&2 fi done -# Keep running and wait for remaining processes -wait +stop_services + +# Carry the dead service's own status where there is one, but never exit 0: that +# would read as a successful, deliberate shutdown. +if [ "$status" -eq 0 ]; then + status=1 +fi +echo "[entrypoint] Exiting with status $status so the container is restarted" >&2 +exit "$status" diff --git a/docker/test_entrypoint_extra.sh b/docker/test_entrypoint_extra.sh new file mode 100755 index 0000000000..e0b84da366 --- /dev/null +++ b/docker/test_entrypoint_extra.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# +# Supervision tests for docker/entrypoint-extra.sh. +# +# The real script is run unmodified inside a container, with the four services it +# starts replaced by stubs, so what is under test is the shipped file and the +# shipped bash. The stubs are recognised by the argv the entrypoint uses +# (pyls_launcher.py, server.mjs, dap_debug_service.ts, gateway.mjs), which is +# also what keeps the test honest: change how a service is started and the +# corresponding case here stops matching. +# +# Usage: +# bash docker/test_entrypoint_extra.sh +# +# The image defaults to the base of DockerfileExtra's chain (debian:trixie-slim, +# via windmill-ee-slim), so the bash built-ins behave as they do in the real +# image. Override with ENTRYPOINT_TEST_IMAGE to run it against another one, e.g. +# ENTRYPOINT_TEST_IMAGE=windmill-extra:test bash docker/test_entrypoint_extra.sh + +set -euo pipefail + +IMAGE="${ENTRYPOINT_TEST_IMAGE:-debian:trixie-slim}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENTRYPOINT="$SCRIPT_DIR/entrypoint-extra.sh" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required to run this test" >&2 + exit 1 +fi + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +# --- the pieces that run inside the container ----------------------------- + +cat > "$WORKDIR/stub" <<'STUB' +#!/bin/bash +# Stands in for python3 / node / bun. Works out which service it was started as +# from its arguments, then either stays up until it is signalled (recording that +# it was stopped) or exits straight away, to play the crashed service. +name=unknown +for arg in "$@"; do + case "$arg" in + pyls_launcher.py) name=lsp ;; + server.mjs) name=multiplayer ;; + dap_debug_service.ts) name=debugger ;; + gateway.mjs) name=gateway ;; + esac +done +echo "stub[$name] started" + +if [ "$name" = "${STUB_IGNORE_TERM:-}" ]; then + # Plays a service that is wedged or slow to honour SIGTERM. + trap 'echo "stub[$name] ignoring SIGTERM"' TERM +else + trap 'echo "stub[$name] got SIGTERM"; touch "/out/$name.stopped"; exit 0' TERM +fi +touch "/out/$name.ready" + +if [ "$name" = "${STUB_DIE_AS:-}" ]; then + # Only die once every stub in $STUB_WAIT_FOR has its trap installed. Without + # this the entrypoint can reach its shutdown while a later stub is still + # starting, and that stub takes SIGTERM's default action instead of running + # its handler — a race in the test, not in the entrypoint. + for _ in $(seq 1 600); do + ready=1 + for other in ${STUB_WAIT_FOR:-}; do + if [ ! -f "/out/$other.ready" ]; then ready=0; fi + done + if [ "$ready" -eq 1 ]; then break; fi + sleep 0.1 + done + echo "stub[$name] exiting with ${STUB_DIE_CODE:-1}" + exit "${STUB_DIE_CODE:-1}" +fi +# `sleep & wait` rather than a bare `sleep`, so the trap runs as soon as the +# signal arrives instead of after the sleep returns. The loop matters: bash +# resumes after an interrupted `wait`, so without it even the trap that only +# logs would fall off the end of the script and exit. +while true; do + sleep 3000 & + wait $! +done +STUB + +cat > "$WORKDIR/harness.sh" <<'HARNESS' +#!/bin/bash +# Runs inside the container: installs the stubs, then drives the real +# /entrypoint.sh through each scenario. +set -uo pipefail + +mkdir -p /pyls /multiplayer /debugger /out +for tool in python3 node bun; do + cp /harness/stub "/usr/local/bin/$tool" + chmod +x "/usr/local/bin/$tool" +done + +export ENABLE_LSP=true ENABLE_MULTIPLAYER=true ENABLE_DEBUGGER=true ENABLE_GATEWAY=true +# Every stub the dying one must see started before it exits. +export STUB_WAIT_FOR="lsp multiplayer debugger gateway" + +failures=0 +check() { + local what="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo " ok: $what is $expected" + else + echo " FAIL: $what: expected $expected, got $actual" + failures=$((failures + 1)) + fi +} + +check_log() { + local what="$1" pattern="$2" + if grep -qE "$pattern" /out/log; then + echo " ok: $what" + else + echo " FAIL: $what (no line matching /$pattern/)" + failures=$((failures + 1)) + fi +} + +stopped() { + [ -f "/out/$1.stopped" ] && echo yes || echo no +} + +# `wait` on a backgrounded entrypoint, with a watchdog that SIGKILLs it rather +# than letting a wedged shutdown block the run. Sets $wait_status. +wait_bounded() { + local pid="$1" secs="${2:-30}" watchdog + (sleep "$secs"; kill -KILL "$pid" 2>/dev/null) & + watchdog=$! + wait "$pid" + wait_status=$? + kill "$watchdog" 2>/dev/null + wait "$watchdog" 2>/dev/null +} + +# Block until the entrypoint has written $1, so no scenario acts on a script that +# has not reached the state it is about to be tested in. +await_log() { + local pattern="$1" + for _ in $(seq 1 600); do + grep -qE "$pattern" /out/log && return 0 + sleep 0.1 + done + echo " FAIL: timed out waiting for /$pattern/ in the entrypoint log" + failures=$((failures + 1)) + return 1 +} + +echo "== a service that exits takes the container down with it" +rm -rf /out && mkdir -p /out +STUB_DIE_AS=multiplayer STUB_DIE_CODE=3 timeout -k 5 60 bash /entrypoint.sh > /out/log 2>&1 +check "exit status" 3 "$?" +cat /out/log +check_log "the dead service is named in the log" 'ERROR: Multiplayer \(PID: [0-9]+\) has exited' +# The survivors were signalled and ran their own shutdown, rather than being left +# running or killed outright. +for name in lsp debugger gateway; do + check "$name stopped cleanly" yes "$(stopped "$name")" +done + +echo +echo "== a service that exits 0 is still a failure for the container" +rm -rf /out && mkdir -p /out +STUB_DIE_AS=gateway STUB_DIE_CODE=0 timeout -k 5 60 bash /entrypoint.sh > /out/log 2>&1 +check "exit status" 1 "$?" +check_log "the dead service is named in the log" 'ERROR: Gateway \(PID: [0-9]+\) has exited' + +echo +echo "== a service that ignores SIGTERM cannot hold the container open" +rm -rf /out && mkdir -p /out +STUB_DIE_AS=multiplayer STUB_DIE_CODE=3 STUB_IGNORE_TERM=lsp SHUTDOWN_GRACE_SECS=2 \ + timeout -k 5 60 bash /entrypoint.sh > /out/log 2>&1 +check "exit status" 3 "$?" +check_log "the wedged service is killed after the grace period" \ + 'WARNING: PID [0-9]+ did not stop within 2s, killing it' +check_log "the shutdown still completes" 'All services stopped' +# The ones that do honour SIGTERM still stop the polite way. +for name in debugger gateway; do + check "$name stopped cleanly" yes "$(stopped "$name")" +done + +echo +echo "== SIGTERM is still a clean shutdown" +rm -rf /out && mkdir -p /out +bash /entrypoint.sh > /out/log 2>&1 & +entrypoint_pid=$! +if await_log 'All enabled services started'; then + kill -TERM "$entrypoint_pid" + wait_bounded "$entrypoint_pid" + check "exit status" 0 "$wait_status" + for name in lsp multiplayer debugger gateway; do + check "$name stopped cleanly" yes "$(stopped "$name")" + done +else + kill -KILL "$entrypoint_pid" 2>/dev/null +fi + +echo +echo "== with no services enabled the entrypoint sleeps instead of exiting" +rm -rf /out && mkdir -p /out +ENABLE_LSP=false ENABLE_MULTIPLAYER=false ENABLE_DEBUGGER=false ENABLE_GATEWAY=false \ + bash /entrypoint.sh > /out/log 2>&1 & +entrypoint_pid=$! +if await_log 'Sleeping indefinitely'; then + # Staying up is the correct behaviour here, so there is nothing to wait for + # but the passage of time. + sleep 2 + check "still running" yes "$(kill -0 "$entrypoint_pid" 2>/dev/null && echo yes || echo no)" +fi +# SIGKILL, not SIGTERM: this branch blocks in a foreground `sleep infinity`, so +# bash defers the trap until it returns. That is pre-existing behaviour and is +# not what this test is about. +kill -KILL "$entrypoint_pid" 2>/dev/null +wait_bounded "$entrypoint_pid" + +echo +if [ "$failures" -eq 0 ]; then + echo "all entrypoint supervision checks passed" +else + echo "$failures check(s) failed" +fi +exit "$failures" +HARNESS + +echo "Running entrypoint supervision tests in $IMAGE" + +# --entrypoint: the windmill-extra image's own ENTRYPOINT is ["/entrypoint.sh"] +# (docker/DockerfileExtra), which would swallow the command and start the real +# services instead of the harness. Harmless for a plain base image. +DOCKER_RUN=(docker run --rm --init + --entrypoint bash + -v "$ENTRYPOINT:/entrypoint.sh:ro" + -v "$WORKDIR:/harness:ro" + "$IMAGE" /harness/harness.sh) + +# Bound the whole run where the host has a `timeout` (GNU coreutils; stock macOS +# ships none), so a stalled image pull cannot hang a CI job. Every scenario +# inside the container is bounded on its own regardless. +if command -v timeout >/dev/null 2>&1; then + timeout "${ENTRYPOINT_TEST_TIMEOUT:-900}" "${DOCKER_RUN[@]}" +else + "${DOCKER_RUN[@]}" +fi diff --git a/multiplayer/server.mjs b/multiplayer/server.mjs index ee3ef60277..87b20bd66e 100644 --- a/multiplayer/server.mjs +++ b/multiplayer/server.mjs @@ -168,6 +168,13 @@ const getYDoc = (docname) => { return doc } +/** + * Every error message is peer-controlled: `applyAwarenessUpdate` runs + * `JSON.parse` on the peer's bytes and V8 quotes the offending input back. Only + * printable ASCII survives, so nothing in it can forge or flood a log line. + */ +const describeError = (error) => String(error?.message ?? error).replace(/[^\x20-\x7e]+/g, ' ').slice(0, 200) + const send = (conn, message) => { if (conn.readyState === 1) { // WebSocket.OPEN conn.send(message, err => { if (err) console.error(err) }) @@ -176,6 +183,7 @@ const send = (conn, message) => { const setupWSConnection = (conn, req, docName, bufferedMessages = []) => { const doc = getYDoc(docName) + const clientIp = req.socket.remoteAddress // Initialize awareness if (!doc.awareness) { @@ -189,22 +197,36 @@ const setupWSConnection = (conn, req, docName, bufferedMessages = []) => { doc.conns.add(conn) const messageHandler = (message) => { - const data = new Uint8Array(message) - const decoder = decoding.createDecoder(data) - const messageType = decoding.readVarUint(decoder) + // A frame that arrived before we decided to close this connection (or that a + // replay below already made moot) must not still be applied to the document. + if (conn.readyState !== 1) return // WebSocket.OPEN - switch (messageType) { - case messageSync: - const encoder = encoding.createEncoder() - encoding.writeVarUint(encoder, messageSync) - syncProtocol.readSyncMessage(decoder, encoder, doc, null) - if (encoding.length(encoder) > 1) { - send(conn, encoding.toUint8Array(encoder)) + try { + const data = new Uint8Array(message) + const decoder = decoding.createDecoder(data) + const messageType = decoding.readVarUint(decoder) + + switch (messageType) { + case messageSync: { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageSync) + syncProtocol.readSyncMessage(decoder, encoder, doc, null) + if (encoding.length(encoder) > 1) { + send(conn, encoding.toUint8Array(encoder)) + } + break } - break - case messageAwareness: - awarenessProtocol.applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), conn) - break + case messageAwareness: + awarenessProtocol.applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), conn) + break + } + } catch (error) { + // The decoders throw on unparseable input and `ws` re-emits a listener's + // exception on the process, so one bad frame from one peer would end the + // server for every document and client. Drop only the offender, with RFC + // 6455's 1007; the frame itself is untrusted and is never logged. + console.warn(`[${new Date().toISOString()}] MALFORMED MESSAGE: doc="${docName}" from=${clientIp} error="${describeError(error)}"`) + conn.close(1007, 'Invalid message') } } conn.on('message', messageHandler) @@ -259,7 +281,12 @@ const setupWSConnection = (conn, req, docName, bufferedMessages = []) => { }) // Replay, in order, the messages that arrived while the token was being - // verified, now that the handlers above are in place. + // verified, now that the handlers above are in place. The log line is the only + // outside evidence that a frame took this path rather than the live handler, + // and marks a client that beat the JWKS fetch on a slow-starting instance. + if (bufferedMessages.length > 0) { + console.log(`[${new Date().toISOString()}] REPLAY: doc="${docName}" from=${clientIp} messages=${bufferedMessages.length}`) + } bufferedMessages.forEach(messageHandler) } @@ -287,6 +314,14 @@ const server = http.createServer((req, res) => { const wss = new WebSocketServer({ server }) +// `ws` forwards the HTTP server's errors here, and those are fatal: a failed +// listen leaves nothing to serve, and exiting 0 would read as a clean shutdown. +// `process.exitCode` rather than `process.exit()`, which truncates this line. +wss.on('error', (error) => { + console.error(`[${new Date().toISOString()}] WEBSOCKET SERVER ERROR: ${describeError(error)}`) + process.exitCode = 1 +}) + wss.on('connection', async (ws, req) => { let docName = req.url?.slice(1).split('?')[0] || 'unknown' @@ -297,6 +332,14 @@ wss.on('connection', async (ws, req) => { const clientIp = req.socket.remoteAddress + // A frame `ws` cannot parse at the protocol level fails in its Receiver, never + // reaching the handler below, and an unhandled 'error' on an EventEmitter ends + // the process. Attached before authentication, since the pre-auth window is + // exposed too; `ws` has already closed the connection by the time this runs. + ws.on('error', (error) => { + console.warn(`[${new Date().toISOString()}] SOCKET ERROR: doc="${docName}" from=${clientIp} error="${describeError(error)}"`) + }) + // Handle ping test — respond and close immediately if (docName === '__ping__') { console.log(`[${new Date().toISOString()}] WS ping from=${clientIp}`) diff --git a/multiplayer/test/cold_start.test.mjs b/multiplayer/test/cold_start.test.mjs index 4c1d37f92d..e2ac68efcb 100644 --- a/multiplayer/test/cold_start.test.mjs +++ b/multiplayer/test/cold_start.test.mjs @@ -15,13 +15,21 @@ import assert from 'node:assert/strict' import test from 'node:test' import crypto from 'node:crypto' import { setTimeout as delay } from 'node:timers/promises' -import { WebSocket } from 'ws' import * as Y from 'yjs' import * as syncProtocol from 'y-protocols/sync' import * as encoding from 'lib0/encoding' import * as decoding from 'lib0/decoding' import { mintToken, startJwksServer, startMultiplayerServer, waitFor } from './helpers.mjs' +import { + hasSyncType, + messageKind, + openClient, + syncStep1Message, + syncStep2, + syncUpdate, + updateMessage +} from './protocol.mjs' const WORKSPACE = 'test_workspace' const DOC_PATH = `${WORKSPACE}/f/foo/bar` @@ -33,53 +41,6 @@ const FLIGHT_MARGIN_MS = 250 // server demonstrably has no key, so anything it receives is in the pre-auth window. const KEY_LOADED = 'Successfully loaded Ed25519 public key' -const messageSync = 0 -const syncStep2 = 1 - -/** `messageSync` + sync step 1 for an empty document, as y-websocket sends on open. */ -function syncStep1Message() { - const encoder = encoding.createEncoder() - encoding.writeVarUint(encoder, messageSync) - syncProtocol.writeSyncStep1(encoder, new Y.Doc()) - return encoding.toUint8Array(encoder) -} - -/** `messageSync` + a Yjs update inserting `text` into the 'content' text type. */ -function updateMessage(text) { - const doc = new Y.Doc() - doc.getText('content').insert(0, text) - const encoder = encoding.createEncoder() - encoding.writeVarUint(encoder, messageSync) - syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(doc)) - return encoding.toUint8Array(encoder) -} - -/** Read the (messageType, syncType) pair that prefixes a sync message. */ -function messageKind(data) { - const decoder = decoding.createDecoder(data) - const messageType = decoding.readVarUint(decoder) - if (messageType !== messageSync) return { messageType } - return { messageType, syncType: decoding.readVarUint(decoder) } -} - -/** Open a socket and collect every frame it receives, plus its close code. */ -function openClient(url, { onOpen } = {}) { - const ws = new WebSocket(url) - const received = [] - const client = { ws, received, closeCode: undefined } - ws.on('message', (data) => received.push(new Uint8Array(data))) - ws.on('close', (code) => { - client.closeCode = code - }) - ws.on('error', () => {}) - if (onOpen) ws.on('open', () => onOpen(ws)) - return client -} - -function hasKind(client, syncType) { - return client.received.some((data) => messageKind(data).syncType === syncType) -} - test('cold start: a sync step 1 sent while the key is still being fetched is answered', { timeout: 60000 }, async (t) => { const jwks = await startJwksServer({ hold: true }) const server = await startMultiplayerServer({ WINDMILL_BASE_URL: jwks.baseUrl }) @@ -112,11 +73,11 @@ test('cold start: a sync step 1 sent while the key is still being fetched is ans await delay(FLIGHT_MARGIN_MS) jwks.release() - await waitFor(() => hasKind(cold, syncStep2), { + await waitFor(() => hasSyncType(cold, syncStep2), { message: 'the server to answer the cold client with sync step 2' }) // The echo of our own update proves it was applied to the server-side doc. - await waitFor(() => hasKind(cold, 2), { + await waitFor(() => hasSyncType(cold, syncUpdate), { message: 'the server to broadcast back the update sent during the cold window' }) cold.ws.close() @@ -127,7 +88,7 @@ test('cold start: a sync step 1 sent while the key is still being fetched is ans }) t.after(() => warm.ws.close()) - await waitFor(() => hasKind(warm, syncStep2), { + await waitFor(() => hasSyncType(warm, syncStep2), { message: 'the server to answer the second client with sync step 2' }) @@ -196,7 +157,7 @@ test('cold start: a peer that floods before authenticating is closed and the ser }) t.after(() => healthy.ws.close()) - await waitFor(() => hasKind(healthy, syncStep2), { + await waitFor(() => hasSyncType(healthy, syncStep2), { message: 'the server to still answer a well-behaved client with sync step 2' }) }) diff --git a/multiplayer/test/helpers.mjs b/multiplayer/test/helpers.mjs index e998cd4961..371123d864 100644 --- a/multiplayer/test/helpers.mjs +++ b/multiplayer/test/helpers.mjs @@ -172,6 +172,12 @@ export async function startMultiplayerServer(env = {}, attemptsLeft = 5) { get output() { return output }, + /** How the child died, or null while it is still running. */ + get exitStatus() { + if (child.exitCode !== null) return `exited with code ${child.exitCode}` + if (child.signalCode !== null) return `killed by ${child.signalCode}` + return null + }, async close() { if (child.exitCode !== null) return const exited = new Promise((resolve) => child.once('exit', resolve)) @@ -181,6 +187,52 @@ export async function startMultiplayerServer(env = {}, attemptsLeft = 5) { } } +/** + * Start server.mjs and wait for it to exit, for the cases where it is expected + * to fail rather than come up. Returns how it died and everything it printed. + */ +export async function runMultiplayerServerUntilExit(env = {}, { timeoutMs = 15000 } = {}) { + const child = spawn(process.execPath, [SERVER_PATH], { + env: { + ...process.env, + REQUIRE_SIGNED_MULTIPLAYER_REQUESTS: 'true', + BASE_INTERNAL_URL: '', + HOST: '127.0.0.1', + ...env + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + let output = '' + child.stdout.on('data', (chunk) => { output += chunk.toString() }) + child.stderr.on('data', (chunk) => { output += chunk.toString() }) + + // Record the rescue rather than inferring it from the exit signal: `signal` is + // null for every child exit on Windows, so a caller checking it could not tell + // a server that exited on its own from one this had to kill. + let killedByTimeout = false + const killer = setTimeout(() => { + // Claim the rescue only if there was something to rescue. The child may have + // exited moments ago with 'close' still pending on the stdio drain — the very + // window this helper waits for — and killing a corpse is not an intervention. + if (child.exitCode !== null || child.signalCode !== null) return + killedByTimeout = child.kill('SIGKILL') + }, timeoutMs) + + try { + // 'close' rather than 'exit': 'exit' fires when the child terminates, which + // can be before its stdio pipes have been drained, and the caller reads + // `output`. A child that never starts emits neither, only 'error'. + const [code, signal] = await new Promise((resolve, reject) => { + child.once('close', (exitCode, exitSignal) => resolve([exitCode, exitSignal])) + child.once('error', reject) + }) + return { code, signal, output, killedByTimeout } + } finally { + clearTimeout(killer) + } +} + /** Poll `predicate` until it is true, or throw after `timeoutMs`. */ export async function waitFor(predicate, { timeoutMs = 15000, intervalMs = 10, message = 'condition' } = {}) { const deadline = Date.now() + timeoutMs diff --git a/multiplayer/test/malformed_frame.test.mjs b/multiplayer/test/malformed_frame.test.mjs new file mode 100644 index 0000000000..8ba340de37 --- /dev/null +++ b/multiplayer/test/malformed_frame.test.mjs @@ -0,0 +1,231 @@ +/** + * Regression tests for malformed frames sent by an authenticated peer. + * + * The 'message' handler in `setupWSConnection` decodes whatever arrives with + * `decoding.readVarUint`, `syncProtocol.readSyncMessage` and + * `awarenessProtocol.applyAwarenessUpdate`. All three throw on input they cannot + * parse, and `ws` emits the listener's exception on the process. With no + * `uncaughtException` handler installed, one such frame used to take the whole + * multiplayer server down — every other document and every other client with it. + * + * The payloads below are the ones that reproduce it: 600 KiB of zeros decodes as + * sync step 1 with an empty state vector, which makes `Y.encodeStateAsUpdate` + * throw "Unexpected end of array"; the truncated frames run off the end of the + * buffer inside the decoder. + */ + +import assert from 'node:assert/strict' +import test from 'node:test' +import { setTimeout as delay } from 'node:timers/promises' +import * as encoding from 'lib0/encoding' + +import { mintToken, startJwksServer, startMultiplayerServer, waitFor } from './helpers.mjs' +import { + hasSyncType, + messageAwareness, + messageSync, + openClient, + syncedDoc, + syncStep1, + syncStep1Message, + syncStep2, + updateMessage +} from './protocol.mjs' + +const WORKSPACE = 'test_workspace' +const DOC_PATH = `${WORKSPACE}/f/foo/bar` +// server.mjs closes a connection whose frame it could not decode with 1007 +// ("invalid frame payload data"). +const INVALID_PAYLOAD = 1007 +// Logged by server.mjs for every frame it refused; asserted on so the tests +// cannot pass on a connection that was closed for some unrelated reason. +const REFUSED = 'MALFORMED MESSAGE' +// Logged by server.mjs once the JWKS fetch resolves. While it is absent the +// server demonstrably has no key, so anything it received is in the pre-auth window. +const KEY_LOADED = 'Successfully loaded Ed25519 public key' +// Grace for a frame the client has already written to be delivered over loopback +// and read by the (otherwise idle) server, before the key is released. Only the +// already-written bytes have to land in this time, and the REPLAY assertion +// below fails loudly if they did not, rather than quietly testing the wrong path. +const FLIGHT_MARGIN_MS = 500 +// Logged by server.mjs when, and only when, it replays messages that were +// buffered before authentication. +const REPLAYED = 'REPLAY:' +// A control character a peer would use to forge log lines or drive a terminal. +const ESC = '\x1b' + +/** + * 600 KiB of zeros: `messageSync`, then sync step 1 with a zero-length state + * vector, which `Y.encodeStateAsUpdate` cannot decode. + */ +function zeroFlood() { + return Buffer.alloc(600 * 1024) +} + +/** `messageSync` with nothing after it: `readSyncMessage` reads past the end. */ +function truncatedSyncMessage() { + return Uint8Array.from([messageSync]) +} + +/** `messageSync` + sync step 1 whose state vector is shorter than it claims. */ +function truncatedStateVector() { + return Uint8Array.from([messageSync, syncStep1, 8, 1, 2, 3]) +} + +/** `messageAwareness` + an awareness payload shorter than it claims. */ +function truncatedAwarenessMessage() { + return Uint8Array.from([messageAwareness, 8, 1, 2, 3]) +} + +/** + * `messageAwareness` + one well-formed entry whose state is not JSON, so + * `applyAwarenessUpdate` fails inside `JSON.parse`. V8 quotes the offending + * input back in its message, which is how peer bytes — ESC included — can reach + * a log line that interpolates `error.message`. + */ +function awarenessWithUnparseableState() { + const update = encoding.createEncoder() + encoding.writeVarUint(update, 1) // one client + encoding.writeVarUint(update, 42) // client id + encoding.writeVarUint(update, 1) // clock + // Not starting with '{': V8 only quotes the input back for a value that is + // invalid from position 0, which is the case this needs to reproduce. + encoding.writeVarString(update, `x${ESC}[2J OWNED THE LOG`) + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageAwareness) + encoding.writeVarUint8Array(encoder, encoding.toUint8Array(update)) + return encoding.toUint8Array(encoder) +} + +const MALFORMED = [ + ['600 KiB of zeros', zeroFlood], + ['a truncated sync message', truncatedSyncMessage], + ['a truncated sync step 1 state vector', truncatedStateVector], + ['a truncated awareness update', truncatedAwarenessMessage], + ['an awareness state that is not JSON', awarenessWithUnparseableState] +] + +/** Connect, wait for the server's sync step 1, i.e. for the peer to be authenticated. */ +async function connectAuthenticated(server, token, { onOpen } = {}) { + const client = openClient(`${server.url}/${DOC_PATH}?token=${token}`, { onOpen }) + await waitFor(() => hasSyncType(client, syncStep1) || client.closeCode !== undefined, { + message: 'the server to send sync step 1' + }) + assert.equal(client.closeCode, undefined, 'connection was closed before it was set up') + return client +} + +for (const [label, payload] of MALFORMED) { + test(`an authenticated client sending ${label} is closed, and the server survives`, { timeout: 60000 }, async (t) => { + const jwks = await startJwksServer() + const server = await startMultiplayerServer({ WINDMILL_BASE_URL: jwks.baseUrl }) + t.after(async () => { + await server.close() + await jwks.close() + }) + + const token = mintToken(jwks.privateKey, { workspaceId: WORKSPACE }) + + // A bystander on the same document, synced before anything goes wrong. + const bystander = await connectAuthenticated(server, token, { + onOpen: (ws) => ws.send(syncStep1Message()) + }) + await waitFor(() => hasSyncType(bystander, syncStep2), { + message: 'the server to answer the bystander with sync step 2' + }) + t.after(() => bystander.ws.close()) + + // The offender is fully authenticated — the server has already answered it + // with sync step 1 — so this frame goes through the live message handler, + // not the pre-auth buffer. + const offender = await connectAuthenticated(server, token) + offender.ws.send(payload()) + + await waitFor(() => offender.closeCode !== undefined, { + message: 'the offending connection to be closed' + }) + assert.equal(offender.closeCode, INVALID_PAYLOAD) + assert.equal(server.exitStatus, null, `server died: ${server.output}`) + // The frame itself is never logged: 600 KiB of zeros must not reach the log. + assert.ok(server.output.length < 8192, `server logged ${server.output.length} bytes, payload leaked?`) + + // Exactly one refusal line, and every assertion about it is made against the + // line itself: CONNECT and DISCONNECT also name the document, so checking the + // whole log would pass even if the refusal stopped naming anything. + const refusals = server.output.split('\n').filter((line) => line.includes(REFUSED)) + assert.equal(refusals.length, 1, `expected one refusal line, got:\n${server.output}`) + assert.ok(refusals[0].includes(`doc="${DOC_PATH}"`), `the refusal must name the document: ${refusals[0]}`) + assert.ok(refusals[0].includes('from='), `the refusal must name the peer: ${refusals[0]}`) + // Nothing a peer put in the frame can escape that line: an error message can + // quote the payload (V8 does so for JSON.parse), so it must carry no control + // characters at all. + assert.doesNotMatch(refusals[0], /[\x00-\x1f\x7f]/, 'the refusal line must have no control characters') + + // The bystander was not disturbed, and a new client still syncs. + assert.equal(bystander.closeCode, undefined) + const latecomer = await connectAuthenticated(server, token, { + onOpen: (ws) => ws.send(syncStep1Message()) + }) + t.after(() => latecomer.ws.close()) + await waitFor(() => hasSyncType(latecomer, syncStep2), { + message: 'the server to answer a new client with sync step 2' + }) + + // And a real edit still propagates from one client to the other. + const bystanderDoc = syncedDoc(bystander) + latecomer.ws.send(updateMessage('hello')) + await waitFor(() => bystanderDoc().getText('content').toString() === 'hello', { + message: 'the edit to propagate to the bystander' + }) + }) +} + +test('a malformed frame replayed from the pre-auth buffer is refused, not fatal', { timeout: 60000 }, async (t) => { + const jwks = await startJwksServer({ hold: true }) + const server = await startMultiplayerServer({ WINDMILL_BASE_URL: jwks.baseUrl }) + t.after(async () => { + await server.close() + await jwks.close() + }) + + const token = mintToken(jwks.privateKey, { workspaceId: WORKSPACE }) + + // The key is parked, so this frame is buffered by the pre-auth path and only + // decoded later, when `setupWSConnection` replays it. One 600 KiB frame stays + // under MAX_PREAUTH_BYTES (1 MiB), so the flood cap cannot be what closes it. + await waitFor(() => jwks.requests >= 1, { message: 'the server to request the JWKS' }) + let framesWritten = 0 + const offender = openClient(`${server.url}/${DOC_PATH}?token=${token}`, { + onOpen: (ws) => ws.send(zeroFlood(), () => framesWritten++) + }) + await waitFor(() => framesWritten === 1, { message: 'the offending client to write its frame' }) + // The frame is on the wire while the server demonstrably has no key, so it + // cannot have been authenticated yet. + assert.ok(!server.output.includes(KEY_LOADED)) + await delay(FLIGHT_MARGIN_MS) + jwks.release() + + await waitFor(() => offender.closeCode !== undefined, { + message: 'the offending connection to be closed' + }) + assert.equal(offender.closeCode, INVALID_PAYLOAD) + // Without this the test would pass just as well if the frame had arrived after + // setup and gone through the live handler — the same close code, the same log + // line — so it would silently stop covering the replay path it is named for. + // server.mjs logs REPLAY only when it replays a pre-auth buffer. + assert.ok( + server.output.includes(`${REPLAYED} doc="${DOC_PATH}" from=127.0.0.1 messages=1`), + `the frame did not go through the pre-auth buffer:\n${server.output}` + ) + assert.equal(server.exitStatus, null, `server died: ${server.output}`) + assert.ok(server.output.includes(REFUSED), `server did not log the refused frame:\n${server.output}`) + + // The server is unharmed and still syncs a well-behaved client. + const healthy = await connectAuthenticated(server, token, { + onOpen: (ws) => ws.send(syncStep1Message()) + }) + t.after(() => healthy.ws.close()) + await waitFor(() => hasSyncType(healthy, syncStep2), { + message: 'the server to still answer a well-behaved client with sync step 2' + }) +}) diff --git a/multiplayer/test/protocol.mjs b/multiplayer/test/protocol.mjs new file mode 100644 index 0000000000..77aeea3db9 --- /dev/null +++ b/multiplayer/test/protocol.mjs @@ -0,0 +1,94 @@ +/** + * y-websocket protocol helpers shared by the multiplayer server tests: + * building the frames a real client sends, reading back what the server + * answers, and a socket wrapper that records both. + */ + +import { WebSocket } from 'ws' +import * as Y from 'yjs' +import * as syncProtocol from 'y-protocols/sync' +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' + +// Message types, as server.mjs numbers them. +export const messageSync = 0 +export const messageAwareness = 1 + +// y-protocols/sync sub-types. These overlap numerically with the message types +// above (`syncStep1 === messageSync === 0`), so no runtime check can catch a +// caller that passes the wrong family: `hasSyncType` takes only these three. +export const syncStep1 = 0 +export const syncStep2 = 1 +export const syncUpdate = 2 + +/** `messageSync` + sync step 1 for an empty document, as y-websocket sends on open. */ +export function syncStep1Message() { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageSync) + syncProtocol.writeSyncStep1(encoder, new Y.Doc()) + return encoding.toUint8Array(encoder) +} + +/** `messageSync` + a Yjs update inserting `text` into the 'content' text type. */ +export function updateMessage(text) { + const doc = new Y.Doc() + doc.getText('content').insert(0, text) + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageSync) + syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(doc)) + return encoding.toUint8Array(encoder) +} + +/** Read the (messageType, syncType) pair that prefixes a sync message. */ +export function messageKind(data) { + const decoder = decoding.createDecoder(data) + const messageType = decoding.readVarUint(decoder) + if (messageType !== messageSync) return { messageType } + return { messageType, syncType: decoding.readVarUint(decoder) } +} + +/** + * A live mirror of what `client` has been sent: one document that each call + * brings up to date with the frames received since the last one. Cheap enough + * to call from a polling predicate, and never re-applies a frame. + */ +export function syncedDoc(client) { + const doc = new Y.Doc() + let applied = 0 + return () => { + while (applied < client.received.length) { + const data = client.received[applied++] + if (messageKind(data).messageType !== messageSync) continue + const decoder = decoding.createDecoder(data) + decoding.readVarUint(decoder) // messageSync + syncProtocol.readSyncMessage(decoder, encoding.createEncoder(), doc, null) + } + return doc + } +} + +/** Open a socket and collect every frame it receives, plus its close code. */ +export function openClient(url, { onOpen } = {}) { + const ws = new WebSocket(url) + const received = [] + const client = { ws, received, closeCode: undefined, lastError: undefined } + // Under the default binaryType 'nodebuffer' `ws` hands every frame over as a + // Buffer, text frames included, so this is lossless for both. + ws.on('message', (data) => received.push(new Uint8Array(data))) + ws.on('close', (code) => { + client.closeCode = code + }) + // Keep the error rather than only silencing it: without a listener `ws` would + // throw, and without the record a failed connection shows up as a `waitFor` + // timeout with no cause attached. + ws.on('error', (error) => { + client.lastError = error + }) + if (onOpen) ws.on('open', () => onOpen(ws)) + return client +} + +/** Has `client` received a sync message of this sub-type (never a message type)? */ +export function hasSyncType(client, syncType) { + return client.received.some((data) => messageKind(data).syncType === syncType) +} diff --git a/multiplayer/test/socket_error.test.mjs b/multiplayer/test/socket_error.test.mjs new file mode 100644 index 0000000000..4978906a50 --- /dev/null +++ b/multiplayer/test/socket_error.test.mjs @@ -0,0 +1,128 @@ +/** + * Regression tests for WebSocket-level protocol errors. + * + * A frame the `ws` library itself cannot parse — an unmasked frame from a + * client, a reserved opcode, a bad RSV bit — never reaches the application's + * 'message' handler. `ws` fails it in its Receiver and emits an 'error' on the + * WebSocket (`receiverOnError` in ws/lib/websocket.js), and an unhandled 'error' + * on an EventEmitter throws, so the process used to exit here too — the same + * blast radius as a malformed application payload, reached one layer lower. + * + * Raw bytes are written straight to the TCP socket, since the `ws` client would + * never produce an illegal frame on its own. + */ + +import assert from 'node:assert/strict' +import net from 'node:net' +import test from 'node:test' + +import { + mintToken, + runMultiplayerServerUntilExit, + startJwksServer, + startMultiplayerServer, + waitFor +} from './helpers.mjs' +import { hasSyncType, openClient, syncStep1, syncStep1Message, syncStep2 } from './protocol.mjs' + +const WORKSPACE = 'test_workspace' +const DOC_PATH = `${WORKSPACE}/f/foo/bar` +// Logged by server.mjs for every connection `ws` failed at the protocol level. +const SOCKET_ERROR = 'SOCKET ERROR' +// RFC 6455 "protocol error": what `ws` closes with when its Receiver rejects a +// frame, carried on the Receiver's error as `Symbol(status-code)`. +const PROTOCOL_ERROR = 1002 + +/** + * A FIN + text frame of 3 bytes with the MASK bit clear. RFC 6455 requires every + * client-to-server frame to be masked, so `ws` rejects it with + * WS_ERR_EXPECTED_MASK (close status 1002). + */ +const UNMASKED_FRAME = Buffer.from([0x81, 0x03, 0x61, 0x62, 0x63]) + +/** Assert the server is alive by making it serve a fresh client. */ +async function assertStillServing(t, server, token) { + const healthy = openClient(`${server.url}/${DOC_PATH}?token=${token}`, { + onOpen: (ws) => ws.send(syncStep1Message()) + }) + t.after(() => healthy.ws.close()) + // Resolve as soon as either outcome is settled, so a dead server fails fast + // and with its own output rather than by timing out. + await waitFor(() => hasSyncType(healthy, syncStep2) || server.exitStatus !== null, { + message: 'the server to answer a new client with sync step 2' + }) + assert.equal(server.exitStatus, null, `server died: ${server.output}`) + assert.ok(hasSyncType(healthy, syncStep2)) +} + +test('an illegal WebSocket frame from an authenticated client does not exit the server', { timeout: 60000 }, async (t) => { + const jwks = await startJwksServer() + const server = await startMultiplayerServer({ WINDMILL_BASE_URL: jwks.baseUrl }) + t.after(async () => { + await server.close() + await jwks.close() + }) + + const token = mintToken(jwks.privateKey, { workspaceId: WORKSPACE }) + + const offender = openClient(`${server.url}/${DOC_PATH}?token=${token}`) + // The server's sync step 1 means this peer is past authentication. + await waitFor(() => hasSyncType(offender, syncStep1), { message: 'the server to send sync step 1' }) + offender.ws._socket.write(UNMASKED_FRAME) + + await waitFor(() => offender.closeCode !== undefined, { + message: 'the offending connection to be closed' + }) + assert.equal(offender.closeCode, PROTOCOL_ERROR) + assert.equal(server.exitStatus, null, `server died: ${server.output}`) + assert.ok(server.output.includes(SOCKET_ERROR), `server did not log the socket error:\n${server.output}`) + + await assertStillServing(t, server, token) +}) + +test('an illegal WebSocket frame before authentication does not exit the server', { timeout: 60000 }, async (t) => { + const jwks = await startJwksServer({ hold: true }) + const server = await startMultiplayerServer({ WINDMILL_BASE_URL: jwks.baseUrl }) + t.after(async () => { + await server.close() + await jwks.close() + }) + + const token = mintToken(jwks.privateKey, { workspaceId: WORKSPACE }) + + // The JWKS response is parked and never released before the assertions below, + // so the server cannot authenticate anyone for the whole of this test. + const offender = openClient(`${server.url}/${DOC_PATH}?token=${token}`, { + onOpen: (ws) => ws._socket.write(UNMASKED_FRAME) + }) + + await waitFor(() => offender.closeCode !== undefined, { + message: 'the offending connection to be closed' + }) + assert.equal(offender.closeCode, PROTOCOL_ERROR) + // server.mjs logs CONNECT only once a peer is past verification, so its absence + // is what makes this the pre-auth case rather than a repeat of the test above. + assert.ok(!server.output.includes('CONNECT:'), `a connection was accepted:\n${server.output}`) + assert.equal(server.exitStatus, null, `server died: ${server.output}`) + assert.ok(server.output.includes(SOCKET_ERROR), `server did not log the socket error:\n${server.output}`) + + jwks.release() + await assertStillServing(t, server, token) +}) + +test('a server-level error is fatal, not swallowed', { timeout: 60000 }, async (t) => { + // Hold the port so server.mjs's listen fails with EADDRINUSE. `ws` forwards + // the HTTP server's errors to the WebSocketServer, so this is what reaches the + // `wss.on('error')` handler — and a process with no listening socket must not + // report a clean exit, or nothing upstream knows to replace it. + const blocker = net.createServer() + await new Promise((resolve) => blocker.listen(0, '127.0.0.1', resolve)) + const { port } = blocker.address() + t.after(() => new Promise((resolve) => blocker.close(resolve))) + + const { code, output, killedByTimeout } = await runMultiplayerServerUntilExit({ PORT: String(port) }) + + assert.equal(killedByTimeout, false, `the server had to be killed rather than exiting:\n${output}`) + assert.ok(output.includes('EADDRINUSE'), `expected a listen failure, got:\n${output}`) + assert.notEqual(code, 0, `expected a non-zero exit, got ${code}:\n${output}`) +}) From 3bd89e92d8f563b564a3d2e619749f377a7d6982 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 25 Sep 2026 17:12:51 +0200 Subject: [PATCH 17/51] feat: collapse the fork members setting and show its state in a badge (#11220) * feat: collapse the fork members setting and show its state in a badge Co-Authored-By: Claude Opus 5 * fix: show the fork members badge next to the title, only when on, like other section badges Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../lib/components/settings/WorkspaceUserSettings.svelte | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index 449e9c7d31..8f5db5f2d9 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -1091,7 +1091,13 @@ label="Fork members" description="Choose who a new fork of this workspace starts with, besides the person who creates it." wrapperClass="pb-12" + collapsable > + {#snippet badge()} + {#if addAdminsAndDevelopersToForks} + Admins and developers + {/if} + {/snippet} updateAddAdminsAndDevelopersToForks(e.detail)} From f183bd43fbfcc288a8bd8ce097296777b96ca2fc Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 25 Sep 2026 11:13:06 -0400 Subject: [PATCH 18/51] chore: retire the standalone lsp and multiplayer images from examples, drop lsp/Dockerfile (#11341) * chore: retire the standalone lsp and multiplayer images from examples, drop lsp/Dockerfile Co-Authored-By: Claude Opus 5.5 (1M context) * chore: drop the unbuilt DockerfileMultiplayer, document running the LSP from windmill-extra Co-Authored-By: Claude Opus 5.5 (1M context) * fix(examples): ecs terraform destroys cleanly and gives private instances no public ip Co-Authored-By: Claude Opus 5.5 (1M context) * fix(examples): give windmill-extra on ecs a WINDMILL_BASE_URL for multiplayer auth, address review Co-Authored-By: Claude Opus 5.5 (1M context) * fix(examples): make the ecs example upgrade cleanly from the standalone lsp/multiplayer stack Co-Authored-By: Claude Opus 5.5 (1M context) * fix(examples): name the extra target group by prefix so create_before_destroy can replace it Co-Authored-By: Claude Opus 5.5 (1M context) * docs(examples): note the brief editor-socket gap when upgrading the ecs example Co-Authored-By: Claude Opus 5.5 (1M context) * docs(examples): the debugger stays off after the ecs upgrade unless enabled Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- docker/DockerfileMultiplayer | 9 -- docs/docker-security.md | 5 +- examples/deploy/aws-ecs-terraform/README.md | 18 ++- .../deploy/aws-ecs-terraform/ecs_cluster.tf | 17 ++- .../deploy/aws-ecs-terraform/load_balancer.tf | 50 ++++--- examples/deploy/aws-ecs-terraform/rds.tf | 9 +- examples/deploy/aws-ecs-terraform/vpc.tf | 6 + .../aws-ecs-terraform/windmill_extra.tf | 126 ++++++++++++++++++ .../deploy/aws-ecs-terraform/windmill_lsp.tf | 85 ------------ .../aws-ecs-terraform/windmill_multiplayer.tf | 80 ----------- .../windmill_worker_high_performance.tf | 17 ++- .../aws-eks-cloudformation/quicklaunch.yaml | 2 +- .../otel-tracing-grafana/docker-compose.yml | 33 +++-- .../otel-tracing-jaeger/docker-compose.yml | 33 +++-- frontend/README_DEV.md | 18 +++ lsp/Dockerfile | 62 --------- lsp/dev.nu | 21 --- 17 files changed, 262 insertions(+), 329 deletions(-) delete mode 100644 docker/DockerfileMultiplayer create mode 100644 examples/deploy/aws-ecs-terraform/windmill_extra.tf delete mode 100644 examples/deploy/aws-ecs-terraform/windmill_lsp.tf delete mode 100644 examples/deploy/aws-ecs-terraform/windmill_multiplayer.tf delete mode 100644 lsp/Dockerfile delete mode 100755 lsp/dev.nu diff --git a/docker/DockerfileMultiplayer b/docker/DockerfileMultiplayer deleted file mode 100644 index 192ac06ff5..0000000000 --- a/docker/DockerfileMultiplayer +++ /dev/null @@ -1,9 +0,0 @@ -FROM node:slim - -EXPOSE 3002 -ENV PORT 3002 -ENV HOST 0.0.0.0 - -RUN npm install -g y-websocket - -CMD ["npx", "y-websocket"] \ No newline at end of file diff --git a/docs/docker-security.md b/docs/docker-security.md index 5b5196f8b4..44ae6cbeff 100644 --- a/docs/docker-security.md +++ b/docs/docker-security.md @@ -22,9 +22,8 @@ so patching here is sufficient: - `DockerfileExtra` builds `FROM windmill-ee-slim`. The nsjail *builder* stages are throwaway (only the compiled `nsjail` binary is -copied out), so they are intentionally not upgraded. `DockerfileMultiplayer` -(`node:slim`), the CLI, Caddy-L4, CUDA-only, and RHEL/dnf images are out of scope -for this apt-based patching. +copied out), so they are intentionally not upgraded. The CLI, Caddy-L4, +CUDA-only, and RHEL/dnf images are out of scope for this apt-based patching. ## Why `apt-get upgrade` and not `unattended-upgrades` / pinning diff --git a/examples/deploy/aws-ecs-terraform/README.md b/examples/deploy/aws-ecs-terraform/README.md index 098943e353..f9a0318d7a 100644 --- a/examples/deploy/aws-ecs-terraform/README.md +++ b/examples/deploy/aws-ecs-terraform/README.md @@ -32,14 +32,13 @@ Windmill relies on a PostgreSQL database which will be created by Terraform. The The following stack will be deployed by this terraform as it is provided: - 2 Windmill servers (each requiring 1 CPU and 1.5GiB of Memory) - 2 multi-purpose Windmill workers (each requiring 2 CPU and 3GiB of Memory) -- Windmill LSP (requiring 1 CPU and 1.5GiB of Memory) -- Windmill Multiplayer (requiring 1 CPU and 1.5GiB of Memory) +- Windmill Extra: LSP and Multiplayer, plus the optional Debugger (requiring 1 CPU and 1.5GiB of Memory) - 1 native Windmill worker (requiring 2 CPU and 3GiB of Memory) - 1 high performance Windmill worker (requiring 4 CPU and 15GiB of Memory) -The ECS cluster will be composed of 1 autoscaling group composed of up-to 6 `t3.medium` instances (5 necessary for the entire load, and 1 more for rolling upgrades). This will host all the services except the high performance workers. Those will be deployed on a separate auto-scaling group composed of up-to 2 `t3.xlarge` instances. +The ECS cluster will be composed of 1 autoscaling group of `t3.medium` instances, sized by the ECS capacity provider up to `max_size` (10) in [ecs_cluster.tf](./ecs_cluster.tf). In our test deployment it settled on 5 instances for this load; the headroom covers rolling upgrades. This will host all the services except the high performance workers. Those will be deployed on a separate auto-scaling group composed of up-to 2 `t3.xlarge` instances. -Of course the above is provided as an example, it should be tuned for you own needs, both in terms of instance specs, but also in terms of service architecture. For example, you might not need high performance workers, in which case you won't need the second autoscaling group at all. Same for the native worker, multiplayer, or even LSP (though LSP is highly recommended for a better coding experience). +Of course the above is provided as an example, it should be tuned for your own needs, both in terms of instance specs, but also in terms of service architecture. For example, you might not need high performance workers, in which case you won't need the second autoscaling group at all. Same for the native worker, or even Windmill Extra (though its LSP is highly recommended for a better coding experience). Its services are switched on and off with the `ENABLE_LSP`, `ENABLE_MULTIPLAYER` (Enterprise Edition) and `ENABLE_DEBUGGER` variables in [windmill_extra.tf](./windmill_extra.tf). The only strictly required components are: - At least 1 Windmill server @@ -49,14 +48,13 @@ This terraform has been assembled to easily remove components. Each component me - _REQUIRED_ Windmill server -> [windmill_server.tf](./windmill_server.tf) - _REQUIRED_ Multi-purpose windmill worker -> [windmill_worker_basic.tf](./windmill_worker_basic.tf) -- _OPTIONAL_ Windmill LSP -> [windmill_lsp.tf](./windmill_lsp.tf) -- _OPTIONAL_ Windmill Multiplayer -> [windmill_multiplayer.tf](./windmill_multiplayer.tf) +- _OPTIONAL_ Windmill Extra (LSP, Multiplayer, Debugger) -> [windmill_extra.tf](./windmill_extra.tf) - _OPTIONAL_ Windmill Native worker -> [windmill_worker_native.tf](./windmill_worker_native.tf) - _OPTIONAL_ Windmill High Performace worker -> [windmill_worker_high_performance.tf](./windmill_worker_high_performance.tf) - this one is a lot longer because it deploys a new auto scaling group ### Network -The network deployed here is a single VPC, composed on 4 subnets spread across 2 availability zones (1 public and 1 private subnet per zone). All the services are behind a load balancer routing the request to Windmill server, LSP or multiplayer. +The network deployed here is a single VPC, composed on 4 subnets spread across 2 availability zones (1 public and 1 private subnet per zone). All the services are behind a load balancer routing the request to Windmill server or Windmill Extra (`/ws/*`, `/ws_mp/*`, `/ws_debug/*`). A single security group is used, allowing HTTP traffic on port 80 (it is not deploying custom certificates and therefore does not use HTTPS). @@ -66,6 +64,12 @@ All this is provided as an example. It can be refined depending on your needs. A Windmill heavily relies on PostgreSQL database. This terraform deploys a standalone RDS instance having 100GiB of storage, which can be scaled up to 1000GiB. For production use cases, we recommend deploying a Multi-AZ instance, or even a Multi-AZ DB cluster. The RDS definition is in [rds.tf](./rds.tf) +### Upgrading a stack deployed before Windmill Extra + +Earlier versions of this example ran the LSP and Multiplayer as two separate services (`windmill_lsp.tf` and `windmill_multiplayer.tf`). A plain `terraform apply` of this version migrates such a stack: the `moved` blocks in [load_balancer.tf](./load_balancer.tf) keep the listener rule in place, and the old services and target groups are removed. + +While the new `windmill-extra` task starts (about 2.5 minutes in our test), the `/ws/*` and `/ws_mp/*` routes answer 503, so code intelligence and multiplayer are unavailable in the editor until it is healthy. The debugger (`/ws_debug/*`) stays off in this example unless you set `ENABLE_DEBUGGER=true` in [windmill_extra.tf](./windmill_extra.tf). The Windmill servers and workers are not affected. + ### Ready? REMINDER: don't forget to edit [terraform.tfvars](./terraform.tfvars) before deploying the stack. diff --git a/examples/deploy/aws-ecs-terraform/ecs_cluster.tf b/examples/deploy/aws-ecs-terraform/ecs_cluster.tf index ee5f74d4c4..ddbaad35cf 100644 --- a/examples/deploy/aws-ecs-terraform/ecs_cluster.tf +++ b/examples/deploy/aws-ecs-terraform/ecs_cluster.tf @@ -17,9 +17,10 @@ resource "aws_launch_template" "windmill_cluster_lt" { } network_interfaces { - device_index = 0 - delete_on_termination = true - associate_public_ip_address = true + device_index = 0 + delete_on_termination = true + # Private subnets: outbound traffic goes through the NAT gateways, so no public IP. + associate_public_ip_address = false security_groups = [ aws_security_group.windmill_cluster_sg.id ] @@ -57,6 +58,16 @@ resource "aws_autoscaling_group" "windmill_cluster_asg" { value = true propagate_at_launch = true } + + # The ECS agent on these instances reaches AWS through private route -> NAT gateway -> public + # route -> internet gateway. Keeping that whole path until the instances are gone lets + # `terraform destroy` drain and delete the ECS services, which otherwise hang in DRAINING. + depends_on = [ + aws_route_table_association.windmill_cluster_subnet_private1__rtb_private1, + aws_route_table_association.windmill_cluster_subnet_private2__rtb_private2, + aws_route_table_association.windmill_cluster_subnet_public1__rtb_public, + aws_route_table_association.windmill_cluster_subnet_public2__rtb_public, + ] } resource "aws_ecs_cluster" "windmill_cluster" { diff --git a/examples/deploy/aws-ecs-terraform/load_balancer.tf b/examples/deploy/aws-ecs-terraform/load_balancer.tf index 376374efb6..f73704f04e 100644 --- a/examples/deploy/aws-ecs-terraform/load_balancer.tf +++ b/examples/deploy/aws-ecs-terraform/load_balancer.tf @@ -6,20 +6,20 @@ resource "aws_lb_target_group" "windmill_cluster_windmill_server_tg" { vpc_id = aws_vpc.windmill_cluster_vpc.id } -resource "aws_lb_target_group" "windmill_cluster_windmill_lsp_tg" { - name = "windmill-cluster-lsp-tg" - port = 3001 +resource "aws_lb_target_group" "windmill_cluster_windmill_extra_tg" { + # A prefix, not a fixed name: with create_before_destroy the replacement exists alongside the old + # group for a moment, and target group names must be unique. + name_prefix = "wmext-" + port = 3000 protocol = "HTTP" target_type = "ip" vpc_id = aws_vpc.windmill_cluster_vpc.id -} -resource "aws_lb_target_group" "windmill_cluster_windmill_multiplayer_tg" { - name = "windmill-cluster-multiplayer-tg" - port = 3002 - protocol = "HTTP" - target_type = "ip" - vpc_id = aws_vpc.windmill_cluster_vpc.id + # Replacing a target group that a listener rule uses: create the new one and repoint the rule + # before deleting the old one, which the ALB refuses while the rule still references it. + lifecycle { + create_before_destroy = true + } } resource "aws_lb" "windmill_cluster_alb" { @@ -48,34 +48,32 @@ resource "aws_lb_listener" "windmill_cluster_alb_listener" { } } -resource "aws_lb_listener_rule" "windmill_cluster_alb_lsp_rule" { +resource "aws_lb_listener_rule" "windmill_cluster_alb_extra_rule" { listener_arn = aws_lb_listener.windmill_cluster_alb_listener.arn priority = 100 action { type = "forward" - target_group_arn = aws_lb_target_group.windmill_cluster_windmill_lsp_tg.arn + target_group_arn = aws_lb_target_group.windmill_cluster_windmill_extra_tg.arn } condition { path_pattern { - values = ["/ws/*"] + values = ["/ws/*", "/ws_mp/*", "/ws_debug/*"] } } } -resource "aws_lb_listener_rule" "windmill_cluster_alb_multiplayer_rule" { - listener_arn = aws_lb_listener.windmill_cluster_alb_listener.arn - priority = 50 +# Upgrading a stack created before windmill-extra (standalone LSP and multiplayer services). +# The LSP rule becomes the extra rule, updated in place: it keeps priority 100, where destroying it and +# creating a new rule with the same priority can fail with PriorityInUse. The LSP target group becomes +# the extra target group, replaced before destroy (see create_before_destroy above). +moved { + from = aws_lb_listener_rule.windmill_cluster_alb_lsp_rule + to = aws_lb_listener_rule.windmill_cluster_alb_extra_rule +} - action { - type = "forward" - target_group_arn = aws_lb_target_group.windmill_cluster_windmill_multiplayer_tg.arn - } - - condition { - path_pattern { - values = ["/ws_mp/*"] - } - } +moved { + from = aws_lb_target_group.windmill_cluster_windmill_lsp_tg + to = aws_lb_target_group.windmill_cluster_windmill_extra_tg } diff --git a/examples/deploy/aws-ecs-terraform/rds.tf b/examples/deploy/aws-ecs-terraform/rds.tf index 610f4a61d2..7cceec7494 100644 --- a/examples/deploy/aws-ecs-terraform/rds.tf +++ b/examples/deploy/aws-ecs-terraform/rds.tf @@ -19,7 +19,7 @@ resource "aws_db_instance" "windmill_cluster_rds" { # iops = 3000 engine = "postgres" - engine_version = "16.1" + engine_version = "16" parameter_group_name = "default.postgres16" license_model = "postgresql-license" @@ -39,4 +39,11 @@ resource "aws_db_instance" "windmill_cluster_rds" { backup_retention_period = 7 skip_final_snapshot = true deletion_protection = false + + # engine_version picks the major version when the database is created; RDS then applies minor + # upgrades itself. Changing it on an existing instance would ask RDS to "upgrade" to the bare major + # version, which it rejects, so Terraform leaves it alone after creation. + lifecycle { + ignore_changes = [engine_version] + } } \ No newline at end of file diff --git a/examples/deploy/aws-ecs-terraform/vpc.tf b/examples/deploy/aws-ecs-terraform/vpc.tf index 6e9c66c6bf..79f0a815a1 100644 --- a/examples/deploy/aws-ecs-terraform/vpc.tf +++ b/examples/deploy/aws-ecs-terraform/vpc.tf @@ -69,6 +69,9 @@ resource "aws_nat_gateway" "windmill_cluster_nat_gateway_public1" { tags = { "Name" = "windmill-cluster-nat-gateway-public1" } + + # Created after, and deleted before, the internet gateway it routes through. + depends_on = [aws_internet_gateway.windmill_cluster_internet_gateway] } resource "aws_eip" "windmill_cluster_nat_gateway_public2_eip" { @@ -81,6 +84,9 @@ resource "aws_nat_gateway" "windmill_cluster_nat_gateway_public2" { tags = { "Name" = "windmill-cluster-nat-gateway-public2" } + + # Created after, and deleted before, the internet gateway it routes through. + depends_on = [aws_internet_gateway.windmill_cluster_internet_gateway] } resource "aws_route_table" "windmill_cluster_rtb_public" { diff --git a/examples/deploy/aws-ecs-terraform/windmill_extra.tf b/examples/deploy/aws-ecs-terraform/windmill_extra.tf new file mode 100644 index 0000000000..ecb4de052d --- /dev/null +++ b/examples/deploy/aws-ecs-terraform/windmill_extra.tf @@ -0,0 +1,126 @@ +# windmill-extra: LSP, Multiplayer and Debugger in one image, behind a gateway on port 3000 +# that routes /ws/* (LSP), /ws_mp/* (Multiplayer) and /ws_debug/* (Debugger). +resource "aws_cloudwatch_log_group" "windmill_cluster_windmill_extra_log_group" { + name = "/ecs/windmill-extra" +} + +resource "aws_ecs_task_definition" "windmill_cluster_windmill_extra_td" { + family = "windmill-extra" + network_mode = "awsvpc" + execution_role_arn = data.aws_iam_role.ecs_task_execution_role.arn + cpu = 1024 + memory = 1536 + runtime_platform { + operating_system_family = "LINUX" + cpu_architecture = "X86_64" + } + requires_compatibilities = ["EC2"] + + container_definitions = jsonencode([ + { + name = "windmill-extra" + image = "ghcr.io/windmill-labs/windmill-extra:latest" + cpu = 1024 + memory = 1536 + essential = true + portMappings = [ + { + name = "http" + containerPort = 3000 + hostPort = 3000 + protocol = "tcp" + appProtocol = "http" + } + ] + environment = [ + { + name = "JSON_FMT" + value = "true" + }, + { + # The target group health check (GET /) is answered by the LSP: keep it enabled, or set a + # health_check path on windmill_cluster_windmill_extra_tg that another enabled service answers. + name = "ENABLE_LSP" + value = "true" + }, + { + # Real-time collaboration, Enterprise Edition only. + name = "ENABLE_MULTIPLAYER" + value = "true" + }, + { + # Keep REQUIRE_SIGNED_DEBUG_REQUESTS=true on any internet-reachable deployment. + name = "ENABLE_DEBUGGER" + value = "false" + }, + { + name = "REQUIRE_SIGNED_DEBUG_REQUESTS" + value = "true" + }, + { + # Multiplayer and the debugger verify the tokens the Windmill server signs, with the key + # they fetch from /api/debug/jwks. Without it, every multiplayer + # session is rejected. Use https:// if you add a TLS listener. + name = "WINDMILL_BASE_URL" + value = "http://${aws_lb.windmill_cluster_alb.dns_name}" + }, + ] + mountPoints = [ + { + sourceVolume = "lsp_cache" + containerPath = "/pyls/.cache" + } + ] + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.windmill_cluster_windmill_extra_log_group.name + "awslogs-region" = data.aws_region.current.name + "awslogs-stream-prefix" = "ecs" + } + } + } + ]) + + volume { + name = "lsp_cache" + host_path = "/pyls/.cache" + } +} + +resource "aws_ecs_service" "windmill_cluster_windmill_extra_service" { + name = "windmill-extra" + cluster = aws_ecs_cluster.windmill_cluster.id + task_definition = aws_ecs_task_definition.windmill_cluster_windmill_extra_td.arn + desired_count = 1 + + network_configuration { + subnets = [ + aws_subnet.windmill_cluster_subnet_private1.id, + aws_subnet.windmill_cluster_subnet_private2.id, + ] + security_groups = [aws_security_group.windmill_cluster_sg.id] + } + + force_new_deployment = true + placement_constraints { + type = "distinctInstance" + } + + capacity_provider_strategy { + capacity_provider = aws_ecs_capacity_provider.windmill_cluster_capacity_provider.name + weight = 100 + } + + load_balancer { + target_group_arn = aws_lb_target_group.windmill_cluster_windmill_extra_tg.arn + container_name = "windmill-extra" + container_port = 3000 + } + + # ECS rejects a service whose target group is not yet attached to the load balancer. + depends_on = [ + aws_autoscaling_group.windmill_cluster_asg, + aws_lb_listener_rule.windmill_cluster_alb_extra_rule, + ] +} diff --git a/examples/deploy/aws-ecs-terraform/windmill_lsp.tf b/examples/deploy/aws-ecs-terraform/windmill_lsp.tf deleted file mode 100644 index 02bd2c5d56..0000000000 --- a/examples/deploy/aws-ecs-terraform/windmill_lsp.tf +++ /dev/null @@ -1,85 +0,0 @@ -resource "aws_cloudwatch_log_group" "windmill_cluster_windmill_lsp_log_group" { - name = "/ecs/windmill-lsp" -} - -resource "aws_ecs_task_definition" "windmill_cluster_windmill_lsp_td" { - family = "windmill-lsp" - network_mode = "awsvpc" - execution_role_arn = data.aws_iam_role.ecs_task_execution_role.arn - cpu = 1024 - memory = 1536 - runtime_platform { - operating_system_family = "LINUX" - cpu_architecture = "X86_64" - } - requires_compatibilities = ["EC2"] - - container_definitions = jsonencode([ - { - name = "windmill-lsp" - image = "ghcr.io/windmill-labs/windmill-lsp:latest" - cpu = 1024 - memory = 1536 - essential = true - portMappings = [ - { - name = "http" - containerPort = 3001 - hostPort = 3001 - protocol = "tcp" - appProtocol = "http" - } - ] - environment = [{ - name = "JSON_FMT" - value = "true" - }] - logConfiguration = { - logDriver = "awslogs" - options = { - "awslogs-group" = aws_cloudwatch_log_group.windmill_cluster_windmill_lsp_log_group.name - "awslogs-region" = data.aws_region.current.name - "awslogs-stream-prefix" = "ecs" - } - } - } - ]) - - volume { - name = "lsp_cache" - host_path = "/pyls/.cache" - } -} - -resource "aws_ecs_service" "windmill_cluster_windmill_lsp_service" { - name = "windmill-lsp" - cluster = aws_ecs_cluster.windmill_cluster.id - task_definition = aws_ecs_task_definition.windmill_cluster_windmill_lsp_td.arn - desired_count = 1 - - network_configuration { - subnets = [ - aws_subnet.windmill_cluster_subnet_private1.id, - aws_subnet.windmill_cluster_subnet_private2.id, - ] - security_groups = [aws_security_group.windmill_cluster_sg.id] - } - - force_new_deployment = true - placement_constraints { - type = "distinctInstance" - } - - capacity_provider_strategy { - capacity_provider = aws_ecs_capacity_provider.windmill_cluster_capacity_provider.name - weight = 100 - } - - load_balancer { - target_group_arn = aws_lb_target_group.windmill_cluster_windmill_lsp_tg.arn - container_name = "windmill-lsp" - container_port = 3001 - } - - depends_on = [aws_autoscaling_group.windmill_cluster_asg] -} diff --git a/examples/deploy/aws-ecs-terraform/windmill_multiplayer.tf b/examples/deploy/aws-ecs-terraform/windmill_multiplayer.tf deleted file mode 100644 index a9ccfa0ee2..0000000000 --- a/examples/deploy/aws-ecs-terraform/windmill_multiplayer.tf +++ /dev/null @@ -1,80 +0,0 @@ -resource "aws_cloudwatch_log_group" "windmill_cluster_windmill_multiplayer_log_group" { - name = "/ecs/windmill-multiplayer" -} - -resource "aws_ecs_task_definition" "windmill_cluster_windmill_multiplayer_td" { - family = "windmill-multiplayer" - network_mode = "awsvpc" - execution_role_arn = data.aws_iam_role.ecs_task_execution_role.arn - cpu = 1024 - memory = 1536 - runtime_platform { - operating_system_family = "LINUX" - cpu_architecture = "X86_64" - } - requires_compatibilities = ["EC2"] - - container_definitions = jsonencode([ - { - name = "windmill-multiplayer" - image = "ghcr.io/windmill-labs/windmill-multiplayer:latest" - cpu = 1024 - memory = 1536 - essential = true - portMappings = [ - { - name = "http" - containerPort = 3002 - hostPort = 3002 - protocol = "tcp" - appProtocol = "http" - } - ] - environment = [{ - name = "JSON_FMT" - value = "true" - }] - logConfiguration = { - logDriver = "awslogs" - options = { - "awslogs-group" = aws_cloudwatch_log_group.windmill_cluster_windmill_multiplayer_log_group.name - "awslogs-region" = data.aws_region.current.name - "awslogs-stream-prefix" = "ecs" - } - } - } - ]) -} - -resource "aws_ecs_service" "windmill_cluster_windmill_multiplayer_service" { - name = "windmill-multiplayer" - cluster = aws_ecs_cluster.windmill_cluster.id - task_definition = aws_ecs_task_definition.windmill_cluster_windmill_multiplayer_td.arn - desired_count = 1 - - network_configuration { - subnets = [ - aws_subnet.windmill_cluster_subnet_private1.id, - aws_subnet.windmill_cluster_subnet_private2.id, - ] - security_groups = [aws_security_group.windmill_cluster_sg.id] - } - - force_new_deployment = true - placement_constraints { - type = "distinctInstance" - } - - capacity_provider_strategy { - capacity_provider = aws_ecs_capacity_provider.windmill_cluster_capacity_provider.name - weight = 100 - } - - load_balancer { - target_group_arn = aws_lb_target_group.windmill_cluster_windmill_multiplayer_tg.arn - container_name = "windmill-multiplayer" - container_port = 3002 - } - - depends_on = [aws_autoscaling_group.windmill_cluster_asg] -} diff --git a/examples/deploy/aws-ecs-terraform/windmill_worker_high_performance.tf b/examples/deploy/aws-ecs-terraform/windmill_worker_high_performance.tf index 1c7ff33308..f033d31057 100644 --- a/examples/deploy/aws-ecs-terraform/windmill_worker_high_performance.tf +++ b/examples/deploy/aws-ecs-terraform/windmill_worker_high_performance.tf @@ -17,9 +17,10 @@ resource "aws_launch_template" "windmill_cluster_high_performance_lt" { } network_interfaces { - device_index = 0 - delete_on_termination = true - associate_public_ip_address = true + device_index = 0 + delete_on_termination = true + # Private subnets: outbound traffic goes through the NAT gateways, so no public IP. + associate_public_ip_address = false security_groups = [ aws_security_group.windmill_cluster_sg.id ] @@ -57,6 +58,16 @@ resource "aws_autoscaling_group" "windmill_cluster_high_performance_asg" { value = true propagate_at_launch = true } + + # The ECS agent on these instances reaches AWS through private route -> NAT gateway -> public + # route -> internet gateway. Keeping that whole path until the instances are gone lets + # `terraform destroy` drain and delete the ECS services, which otherwise hang in DRAINING. + depends_on = [ + aws_route_table_association.windmill_cluster_subnet_private1__rtb_private1, + aws_route_table_association.windmill_cluster_subnet_private2__rtb_private2, + aws_route_table_association.windmill_cluster_subnet_public1__rtb_public, + aws_route_table_association.windmill_cluster_subnet_public2__rtb_public, + ] } resource "aws_ecs_capacity_provider" "windmill_cluster_high_performance_capacity_provider" { diff --git a/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml b/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml index 5f2b4c42dc..c26916aa1e 100644 --- a/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml +++ b/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml @@ -436,7 +436,7 @@ Resources: --set windmill.baseDomain=windmill.local \ --set windmill.baseProtocol=http \ --set windmill.appReplicas=${WorkerReplicas} \ - --set windmill.lspReplicas=2 \ + --set windmill.extraReplicas=2 \ --set windmill.workerGroups[0].name=default \ --set windmill.workerGroups[0].mode=worker \ --set windmill.workerGroups[0].replicas=${WorkerReplicas} \ diff --git a/examples/deploy/otel-tracing-grafana/docker-compose.yml b/examples/deploy/otel-tracing-grafana/docker-compose.yml index 525010efad..d75caa0959 100644 --- a/examples/deploy/otel-tracing-grafana/docker-compose.yml +++ b/examples/deploy/otel-tracing-grafana/docker-compose.yml @@ -136,29 +136,34 @@ services: - windmill_index:/tmp/windmill/search - worker_logs:/tmp/windmill/logs - lsp: - image: ghcr.io/windmill-labs/windmill-lsp:latest + # Combined extra services behind one gateway on port 3000: LSP, Multiplayer and Debugger. + # Caddy routes /ws/*, /ws_mp/* and /ws_debug/* here (see the Caddyfile at the repo root). + # - ENABLE_LSP=true (default) - Language Server Protocol for code intelligence + # - ENABLE_MULTIPLAYER=false - Real-time collaboration (Enterprise Edition) + # - ENABLE_DEBUGGER=false - Interactive debugging via DAP WebSocket + windmill_extra: + image: ghcr.io/windmill-labs/windmill-extra:latest pull_policy: always restart: unless-stopped expose: - - 3001 + - 3000 + environment: + - ENABLE_LSP=true + - ENABLE_MULTIPLAYER=false # Set to true to enable multiplayer (Enterprise Edition) + - ENABLE_DEBUGGER=false # Set to true to enable the debugger + - REQUIRE_SIGNED_DEBUG_REQUESTS=true # Do NOT set to false on any internet-reachable deployment + - WINDMILL_BASE_URL=http://windmill_server:8000 volumes: - - lsp_cache:/root/.cache - - multiplayer: - image: ghcr.io/windmill-labs/windmill-multiplayer:latest - deploy: - replicas: 0 # Set to 1 to enable multiplayer, only available on Enterprise Edition - restart: unless-stopped - expose: - - 3002 + - lsp_cache:/pyls/.cache caddy: - image: ghcr.io/windmill-labs/caddy-l4:latest + # Uses the Caddyfile at the repo root. The two are version-coupled: keep this tag in step with + # the caddy image in the root docker-compose.yml. + image: ghcr.io/windmill-labs/caddy-l4:2.11.4-1 restart: unless-stopped # Configure the mounted Caddyfile and the exposed ports or use another reverse proxy if needed volumes: - - ./Caddyfile:/etc/caddy/Caddyfile + - ../../../Caddyfile:/etc/caddy/Caddyfile # - ./certs:/certs # Provide custom certificate files like cert.pem and key.pem to enable HTTPS - See the corresponding section in the Caddyfile ports: # To change the exposed port, simply change 80:80 to :80. No other changes needed diff --git a/examples/deploy/otel-tracing-jaeger/docker-compose.yml b/examples/deploy/otel-tracing-jaeger/docker-compose.yml index 3b186b45fb..b8db8af57e 100644 --- a/examples/deploy/otel-tracing-jaeger/docker-compose.yml +++ b/examples/deploy/otel-tracing-jaeger/docker-compose.yml @@ -136,29 +136,34 @@ services: - windmill_index:/tmp/windmill/search - worker_logs:/tmp/windmill/logs - lsp: - image: ghcr.io/windmill-labs/windmill-lsp:latest + # Combined extra services behind one gateway on port 3000: LSP, Multiplayer and Debugger. + # Caddy routes /ws/*, /ws_mp/* and /ws_debug/* here (see the Caddyfile at the repo root). + # - ENABLE_LSP=true (default) - Language Server Protocol for code intelligence + # - ENABLE_MULTIPLAYER=false - Real-time collaboration (Enterprise Edition) + # - ENABLE_DEBUGGER=false - Interactive debugging via DAP WebSocket + windmill_extra: + image: ghcr.io/windmill-labs/windmill-extra:latest pull_policy: always restart: unless-stopped expose: - - 3001 + - 3000 + environment: + - ENABLE_LSP=true + - ENABLE_MULTIPLAYER=false # Set to true to enable multiplayer (Enterprise Edition) + - ENABLE_DEBUGGER=false # Set to true to enable the debugger + - REQUIRE_SIGNED_DEBUG_REQUESTS=true # Do NOT set to false on any internet-reachable deployment + - WINDMILL_BASE_URL=http://windmill_server:8000 volumes: - - lsp_cache:/root/.cache - - multiplayer: - image: ghcr.io/windmill-labs/windmill-multiplayer:latest - deploy: - replicas: 0 # Set to 1 to enable multiplayer, only available on Enterprise Edition - restart: unless-stopped - expose: - - 3002 + - lsp_cache:/pyls/.cache caddy: - image: ghcr.io/windmill-labs/caddy-l4:latest + # Uses the Caddyfile at the repo root. The two are version-coupled: keep this tag in step with + # the caddy image in the root docker-compose.yml. + image: ghcr.io/windmill-labs/caddy-l4:2.11.4-1 restart: unless-stopped # Configure the mounted Caddyfile and the exposed ports or use another reverse proxy if needed volumes: - - ./Caddyfile:/etc/caddy/Caddyfile + - ../../../Caddyfile:/etc/caddy/Caddyfile # - ./certs:/certs # Provide custom certificate files like cert.pem and key.pem to enable HTTPS - See the corresponding section in the Caddyfile ports: # To change the exposed port, simply change 80:80 to :80. No other changes needed diff --git a/frontend/README_DEV.md b/frontend/README_DEV.md index 9848f333ae..68aa3d7bc9 100644 --- a/frontend/README_DEV.md +++ b/frontend/README_DEV.md @@ -90,6 +90,24 @@ You can configure another proxy to use like so: REMOTE=http://127.0.0.1:8000 REMOTE_LSP=http://127.0.0.1:3001 npm run dev ``` +`REMOTE_LSP` needs a language server on port 3001. The `windmill-extra` image serves one: + +```bash +docker run --rm -p 3001:3001 ghcr.io/windmill-labs/windmill-extra:latest +``` + +To proxy multiplayer and the debugger as well, publish the image's gateway on port 3000 and use `REMOTE_EXTRA`, which +covers `/ws/*`, `/ws_mp/*` and `/ws_debug/*`. Those two services verify tokens signed by your backend, so give the +container its URL (on Linux, add `--add-host=host.docker.internal:host-gateway`): + +```bash +docker run --rm -p 3000:3000 -e WINDMILL_BASE_URL=http://host.docker.internal:8000 ghcr.io/windmill-labs/windmill-extra:latest +REMOTE=http://127.0.0.1:8000 REMOTE_EXTRA=http://127.0.0.1:3000 npm run dev +``` + +To try local changes to `lsp/pyls_launcher.py` or `lsp/Pipfile`, build that image from the repo root with +`docker build -f docker/DockerfileExtra -t windmill-extra-dev .` and run `windmill-extra-dev` instead. + ### Run dev servers on demand A dev server costs 1.1-1.7 GB resident once a page has been browsed, which adds up when diff --git a/lsp/Dockerfile b/lsp/Dockerfile deleted file mode 100644 index b9bba17df2..0000000000 --- a/lsp/Dockerfile +++ /dev/null @@ -1,62 +0,0 @@ -FROM python:3.12-slim as python-base -FROM node:22-slim as node-base - -FROM python-base -COPY --from=node-base /usr/local /usr/local - -ENV PATH="/usr/local/bin:${PATH}" -ENV PIPENV_VENV_IN_PROJECT=1 -ENV XDG_CACHE_HOME=/pyls/.cache - -RUN apt-get update \ - && apt-get install -y shellcheck wget git ca-certificates \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* \ - && pip install pipenv - -RUN npm install -g diagnostic-languageserver pyright - -RUN set -eux; \ - arch="$(dpkg --print-architecture)"; arch="${arch##*-}"; \ - url=; \ - case "$arch" in \ - 'amd64') \ - targz='go1.26.0.linux-amd64.tar.gz'; \ - ;; \ - 'arm64') \ - targz='go1.26.0.linux-arm64.tar.gz'; \ - ;; \ - 'armhf') \ - targz='go1.26.0.linux-armv6l.tar.gz'; \ - ;; \ - *) echo >&2 "error: unsupported architecture '$arch' (likely packaging update needed)"; exit 1 ;; \ - esac; \ - wget "https://golang.org/dl/$targz" -nv && tar -C /usr/local -xzf "$targz" && rm "$targz"; - -ENV PATH="${PATH}:/usr/local/go/bin" -ENV GOBIN=/usr/local/go/bin -RUN /usr/local/go/bin/go install -v golang.org/x/tools/gopls@latest - -RUN pip3 install tornado python-lsp-jsonrpc ruff==0.16.0 - -COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno - -RUN mkdir -p /pyls/.cache - -WORKDIR /pyls -COPY Pipfile . -RUN cat Pipfile -RUN pip install Cython -RUN pipenv install - -COPY pyls_launcher.py . - -RUN mkdir -p /tmp/monaco && chmod -R 777 /tmp/monaco -RUN cd /tmp/monaco && npm install --save-dev windmill-client - -RUN chmod -R a+rX /usr/local && \ - chmod -R a+rX /pyls - -EXPOSE 3001 - -CMD ["sh", "-c", "if [ -n \"$NETRC\" ]; then echo \"$NETRC\" > /root/.netrc && chmod 600 /root/.netrc; fi && if [ -d /root/.cache ]; then export XDG_CACHE_HOME=/root/.cache && cp -r /pyls/.cache /root/.cache; fi && python3 pyls_launcher.py"] diff --git a/lsp/dev.nu b/lsp/dev.nu deleted file mode 100755 index 0beac3cfc1..0000000000 --- a/lsp/dev.nu +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env nu -let tag = $"dev-lsp-(git branch --show-current)"; - -# Build docker image and start server on localhost:3001 -def main [ - --podman(-p) # Use podman - # TODO: - --detach(-d) # Run container in background and print container ID - ] { - if $podman { - podman build --tag $tag .; podman run -p 3001:3001 $tag - } else { - docker build --tag $tag .; docker run -p 3001:3001 $tag - } -} - -# Stop podman container -def "main stop" [] { - try { docker stop (docker ps -q --filter $"ancestor=($tag)") } - try { podman stop (podman ps -q --filter $"ancestor=($tag)") } -} From d3d539291716baf5807a724d60d338cd3c4fe521 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 25 Sep 2026 17:40:13 +0200 Subject: [PATCH 19/51] feat: add an options field to the postgresql resource (#11223) * feat: add an options field to the postgresql resource Co-Authored-By: Claude Opus 5 * fix: keep a literal plus in postgres connection string parameters Co-Authored-By: Claude Opus 5 * fix: pass postgres options to trigger connections Co-Authored-By: Claude Opus 5 * fix: read DATABASE_URL options the way sqlx does Co-Authored-By: Claude Opus 5 * feat: include postgres options in databaseUrlFromResource Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../windmill-api-workspaces/src/workspaces.rs | 6 +++ backend/windmill-common/src/lib.rs | 46 +++++++++++++++++-- backend/windmill-trigger-postgres/src/lib.rs | 6 +++ .../windmill-worker/src/duckdb_executor.rs | 26 ++++++++++- .../src/lib/components/ApiConnectForm.svelte | 3 +- .../AddDataTableWizard.svelte | 11 +++++ .../workspaceSettings/addDataTableModel.ts | 1 + .../utils/postgresConnectionString.test.ts | 17 +++++-- .../src/lib/utils/postgresConnectionString.ts | 24 +++++++--- typescript-client/client.ts | 5 +- 10 files changed, 128 insertions(+), 17 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 8ebd4eeb26..a45c02e348 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3235,6 +3235,9 @@ pub(crate) async fn pg_dump_database( if let Some(ref sslmode) = pg_db.sslmode { cmd.env("PGSSLMODE", sslmode); } + if let Some(options) = pg_db.non_empty_options() { + cmd.env("PGOPTIONS", options); + } let output = cmd .output() @@ -3372,6 +3375,9 @@ fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command { if let Some(ref sslmode) = pg_db.sslmode { cmd.env("PGSSLMODE", sslmode); } + if let Some(options) = pg_db.non_empty_options() { + cmd.env("PGOPTIONS", options); + } cmd } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 1015948882..63ec44daec 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -857,6 +857,7 @@ ta9ELulniZau8zUAtwqwecxodzl+KO8NYj0a9PGgAM64dMqkRtRA8P4UP350Nag3\n\ accept_invalid_certs: None, use_iam_auth: None, region: None, + options: None, } } @@ -944,6 +945,26 @@ ta9ELulniZau8zUAtwqwecxodzl+KO8NYj0a9PGgAM64dMqkRtRA8P4UP350Nag3\n\ assert!(pg(None, None).to_uri().contains("sslmode=prefer")); } + #[test] + fn options_survive_to_uri() { + let mut db = pg(Some("require"), None); + db.options = Some("endpoint=ep-x -c search_path=a&b".to_string()); + let uri = db.to_uri(); + let config: tokio_postgres::Config = uri.parse().unwrap(); + assert_eq!(config.get_options(), db.options.as_deref()); + assert_eq!(PgDatabase::parse_uri(&uri).unwrap().options, db.options); + + db.options = Some(String::new()); + assert!(!db.to_uri().contains("options")); + + // Read the way sqlx reads DATABASE_URL: `+` is a space. + let parsed = PgDatabase::parse_uri("postgres://u@h/db?options=-c+search_path%3Dwm"); + assert_eq!( + parsed.unwrap().options.as_deref(), + Some("-c search_path=wm") + ); + } + /// The other paths default a missing login to `postgres`; Entra must not, or the /// server rejects a role the resource never named. #[test] @@ -994,6 +1015,8 @@ pub struct PgDatabase { pub accept_invalid_certs: Option, pub use_iam_auth: Option, pub region: Option, + /// The libpq `options` startup parameter (e.g. `endpoint=` for Neon, `-c search_path=x`). + pub options: Option, } // Wrapper enum to hold either Tls or NoTls connection @@ -1061,8 +1084,12 @@ impl PgDatabase { } else { urlencoding::encode(&self.host).into_owned() }; + let options = match self.non_empty_options() { + Some(o) => format!("&options={}", urlencoding::encode(o)), + None => String::new(), + }; format!( - "postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode={sslmode}", + "postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode={sslmode}{options}", user = urlencoding::encode(self.login_name()), password = urlencoding::encode(&self.password.as_deref().unwrap_or("")), host = host, @@ -1072,6 +1099,10 @@ impl PgDatabase { ) } + pub fn non_empty_options(&self) -> Option<&str> { + self.options.as_deref().filter(|o| !o.is_empty()) + } + pub async fn connect( &self, main_db: Option<&DB>, @@ -1340,6 +1371,9 @@ impl PgDatabase { .password(token) .dbname(&self.dbname) .ssl_mode(tokio_postgres::config::SslMode::Require); + if let Some(options) = self.non_empty_options() { + config.options(options); + } let (client, connection) = tokio::time::timeout( std::time::Duration::from_secs(20), @@ -1372,9 +1406,14 @@ impl PgDatabase { let port = parsed_url.port(); let dbname = parsed_url.path().trim_start_matches('/').to_string(); let mut sslmode = None; + let mut options = None; + // Form decoding (`+` is a space) on purpose: this parses DATABASE_URL, and the instance's + // own sqlx pool reads it the same way, so connections derived from it must agree. for query in parsed_url.query_pairs() { - if query.0 == "sslmode" { - sslmode = Some(query.1.to_string()); + match query.0.as_ref() { + "sslmode" => sslmode = Some(query.1.to_string()), + "options" => options = Some(query.1.to_string()), + _ => {} } } @@ -1393,6 +1432,7 @@ impl PgDatabase { accept_invalid_certs: None, use_iam_auth: None, region: None, + options, }) } } diff --git a/backend/windmill-trigger-postgres/src/lib.rs b/backend/windmill-trigger-postgres/src/lib.rs index b92860662d..15918fca7b 100644 --- a/backend/windmill-trigger-postgres/src/lib.rs +++ b/backend/windmill-trigger-postgres/src/lib.rs @@ -236,6 +236,8 @@ pub struct Postgres { pub sslmode: String, #[serde(default, deserialize_with = "empty_as_none")] pub root_certificate_pem: Option, + #[serde(default, deserialize_with = "empty_as_none")] + pub options: Option, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] @@ -349,6 +351,10 @@ pub async fn get_raw_postgres_connection( config.password(&database.password); } + if let Some(options) = &database.options { + config.options(options); + } + let connector = build_tls_connector(ssl_mode, database.root_certificate_pem.as_ref())?; let client = if let Some(connector) = connector { let (client, connection) = config.connect(connector).await.map_err(to_anyhow)?; diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index aecdb738b3..943703472f 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -2692,7 +2692,7 @@ fn datatable_secret_name(alias: &str) -> String { } /// ATTACH a datatable's postgres database through a DuckDB TEMPORARY SECRET holding -/// the connection parameters; only sslmode rides in the ATTACH string. +/// the connection parameters; only sslmode and options ride in the ATTACH string. fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result> { let res: PgDatabase = serde_json::from_value(db_resource)?; // Escape single quotes: each field is embedded in a single-quoted DuckDB literal, @@ -2705,6 +2705,15 @@ fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result "require", _ => "prefer", }; + // Nor an options parameter. The value is quoted for libpq's keyword/value syntax first, + // then escaped for the DuckDB literal around it. + let options = match res.non_empty_options() { + Some(o) => esc(&format!( + " options='{}'", + o.replace('\\', "\\\\").replace('\'', "\\'") + )), + None => String::new(), + }; let secret_name = datatable_secret_name(alias_name); Ok(vec![ "INSTALL postgres;".to_string(), @@ -2717,7 +2726,7 @@ fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result' (TYPE postgres, SECRET …)` would reach a database nobody @@ -3976,6 +3985,19 @@ mod tests { } } + #[test] + fn test_pg_secret_attach_statements_options() { + let db_resource = json!({ "host": "h", "dbname": "d", "options": r"-c search_path='a\b'" }); + let stmts = pg_secret_attach_statements(db_resource, "dt").unwrap(); + let secret_name = datatable_secret_name("dt"); + assert_eq!( + stmts[3], + format!( + r"ATTACH 'sslmode=prefer options=''-c search_path=\''a\\b\''''' AS dt (TYPE postgres, SECRET {secret_name});" + ) + ); + } + #[test] fn test_datatable_secret_name_sanitizes_and_disambiguates() { let a = datatable_secret_name("a.b"); diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 2255817c5e..9aff73d827 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -126,7 +126,8 @@ host: parts.host, port: parts.port || args?.port, dbname: parts.dbname || args?.dbname, - sslmode: parts.sslmode || args?.sslmode + sslmode: parts.sslmode || args?.sslmode, + options: parts.options || args?.options }, null, 2 diff --git a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte index 0300ddf310..5e5719f3b5 100644 --- a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte +++ b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte @@ -1455,6 +1455,17 @@ clearable={false} />
+
+ Options + wiz.own.fields.options ?? '', (v) => setField('options', v)} + inputProps={{ placeholder: 'endpoint=ep-example-123456' }} + /> +

+ Startup options sent to the server, such as the endpoint ID a Neon pooled or private + connection needs. +

+
{ expect(parsePostgresConnectionString('postgres://u:p%40ss@host/db')?.password).toBe('p@ss') expect(parsePostgresConnectionString('postgres://u%40corp:p@host/db')?.user).toBe('u@corp') }) + + // libpq only percent-decodes the query, so `+` stays a plus: `Etc/GMT+3` is a timezone, + // `Etc/GMT 3` is a failed connection. + it('keeps a literal + in options, as libpq does', () => { + expect( + parsePostgresConnectionString('postgres://u@h/db?options=-c%20timezone%3DEtc/GMT+3')?.options + ).toBe('-c timezone=Etc/GMT+3') + }) }) // The wizard offers the same connection as a string or as fields and switches between them @@ -94,7 +102,8 @@ describe('composePostgresConnectionString', () => { host: 'db.example.com', port: 6543, dbname: 'mydb', - sslmode: 'require' + sslmode: 'require', + options: 'endpoint=ep-x -c search_path=a&b+c' } expect(parsePostgresConnectionString(composePostgresConnectionString(parts))).toEqual(parts) }) @@ -105,10 +114,10 @@ describe('composePostgresConnectionString', () => { // dangerous ones are precisely the ones a hand-written denylist would miss. describe('unsupportedConnectionParam', () => { it('names a parameter that decides where data lands', () => { - expect(unsupportedConnectionParam('postgres://u:p@h/db?options=-csearch_path%3Dtenant')).toBe( - 'options' - ) expect(unsupportedConnectionParam('postgres://u:p@h/db?search_path=tenant')).toBe('search_path') + expect( + unsupportedConnectionParam('postgres://u:p@h/db?options=-csearch_path%3Dtenant') + ).toBeUndefined() }) // Dropping these saves a *weaker* connection than the one pasted. diff --git a/frontend/src/lib/utils/postgresConnectionString.ts b/frontend/src/lib/utils/postgresConnectionString.ts index d684739904..8b00fbd528 100644 --- a/frontend/src/lib/utils/postgresConnectionString.ts +++ b/frontend/src/lib/utils/postgresConnectionString.ts @@ -35,7 +35,10 @@ function paramsOf(connectionString: string): Map { const query = connectionString.split('?').slice(1).join('?') const params = new Map() if (!query) return params - new URLSearchParams(query).forEach((value, name) => params.set(name, value)) + // URLSearchParams reads `+` as a space, where libpq keeps it (`timezone=Etc/GMT+3`). + new URLSearchParams(query.replaceAll('+', '%2B')).forEach((value, name) => + params.set(name, value) + ) return params } @@ -52,6 +55,7 @@ export type PostgresConnectionParts = { port?: number dbname?: string sslmode?: string + options?: string } /** A lone `%` is not an escape, and a password is free to contain one. */ @@ -73,19 +77,22 @@ export function parsePostgresConnectionString( // By parameter name, never by searching the query text: `sslmode=` also occurs inside // another parameter's *value*, and a substring match there reads someone's // `application_name=sslmode=disable` as a request to turn TLS off. - const sslmode = paramsOf(connectionString).get('sslmode') + const params = paramsOf(connectionString) + const sslmode = params.get('sslmode') + const options = params.get('options') return { user: decode(user), password: password ? decode(password) : undefined, host: host.startsWith('[') ? host.slice(1, -1) : host, port: port ? Number(port) : undefined, dbname: dbname ? decode(dbname) : undefined, - sslmode: sslmode || undefined + sslmode: sslmode || undefined, + options: options || undefined } } -/** The only query parameter the `postgresql` resource has a field for. */ -const REPRESENTABLE_PARAMS = ['sslmode'] +/** The query parameters the `postgresql` resource has a field for. */ +const REPRESENTABLE_PARAMS = ['sslmode', 'options'] /** * Parameters that change nothing about what the connection reaches, how it is secured, or how @@ -135,7 +142,12 @@ export function composePostgresConnectionString(parts: PostgresConnectionParts): ? `${encodeURIComponent(parts.user)}:${encodeURIComponent(parts.password)}` : encodeURIComponent(parts.user) const port = parts.port ? `:${parts.port}` : '' - const query = parts.sslmode ? `?sslmode=${parts.sslmode}` : '' + // encodeURIComponent, not URLSearchParams: libpq does not read `+` as a space. + const params = [ + parts.sslmode && `sslmode=${parts.sslmode}`, + parts.options && `options=${encodeURIComponent(parts.options)}` + ].filter(Boolean) + const query = params.length ? `?${params.join('&')}` : '' const dbname = parts.dbname ? encodeURIComponent(parts.dbname) : '' // A bare IPv6 address would put its own colons where the port separator goes. const host = diff --git a/typescript-client/client.ts b/typescript-client/client.ts index dd2123e746..7e84320346 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -837,7 +837,10 @@ export async function setVariable( */ export async function databaseUrlFromResource(path: string): Promise { const resource = await getResource(path); - return `postgresql://${resource.user}:${resource.password}@${resource.host}:${resource.port}/${resource.dbname}?sslmode=${resource.sslmode}`; + const options = resource.options + ? `&options=${encodeURIComponent(resource.options)}` + : ""; + return `postgresql://${resource.user}:${resource.password}@${resource.host}:${resource.port}/${resource.dbname}?sslmode=${resource.sslmode}${options}`; } // TODO(gb): need to investigate more how Polars and DuckDB work in TS From 94e4fb1c842057e92f3775eac0826ac7e396d725 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 25 Sep 2026 18:15:08 +0200 Subject: [PATCH 20/51] fix: carry labels when deploying variables, resources and folders (#11222) * fix: carry labels when deploying variables, resources and folders across workspaces Co-Authored-By: Claude Opus 5 * fix: clear a folder's labels in the target when the source has none Co-Authored-By: Claude Opus 5 * test: pin that the frontend deploy adapter carries variable labels Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- cli/test/deploy_labels_unit.test.ts | 84 +++++++++++++++++++ cli/windmill-utils-internal/src/deploy.ts | 8 ++ .../src/lib/utils_workspace_deploy.test.ts | 32 +++++++ frontend/src/lib/utils_workspace_deploy.ts | 42 +++++++--- 4 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 cli/test/deploy_labels_unit.test.ts diff --git a/cli/test/deploy_labels_unit.test.ts b/cli/test/deploy_labels_unit.test.ts new file mode 100644 index 0000000000..2686eb8cc6 --- /dev/null +++ b/cli/test/deploy_labels_unit.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import { deployItem } from "../windmill-utils-internal/src/deploy.ts"; + +// Variables, resources and folders are deployed from a body built field by field rather than +// spread from the source item, so any field left out of it is silently dropped in the target. +test("deployItem: carries labels for variables, resources and folders", async () => { + const labels = ["prod", "billing"]; + const writes: [string, any][] = []; + const provider = (exists: boolean) => + ({ + existsVariable: async () => exists, + existsResource: async () => exists, + existsFolder: async () => exists, + getVariable: async () => ({ value: "v", is_secret: false, labels }), + getResource: async () => ({ + value: {}, + resource_type: "postgresql", + labels, + }), + getFolder: async () => ({ + name: "x", + owners: [], + extra_perms: {}, + labels, + }), + ...Object.fromEntries( + [ + "createVariable", + "updateVariable", + "createResource", + "updateResource", + "createFolder", + "updateFolder", + ].map((fn) => [ + fn, + async (p: any) => void writes.push([fn, p.requestBody]), + ]), + ), + }) as any; + + for (const exists of [false, true]) { + for (const [kind, path] of [ + ["variable", "f/x/v"], + ["resource", "f/x/r"], + ["folder", "f/x"], + ]) { + const result = await deployItem( + provider(exists), + kind as any, + path, + "src", + "dst", + ); + expect(result).toEqual({ success: true }); + } + } + + expect(writes.map(([fn]) => fn).sort()).toEqual([ + "createFolder", + "createResource", + "createVariable", + "updateFolder", + "updateResource", + "updateVariable", + ]); + for (const [, body] of writes) expect(body.labels).toEqual(labels); +}); + +test("deployItem: clears a folder's labels when the source has none", async () => { + let body: any; + await deployItem( + { + existsFolder: async () => true, + // A folder with cleared labels reads back without the field at all. + getFolder: async () => ({ name: "x", owners: [], extra_perms: {} }), + updateFolder: async (p: any) => void (body = p.requestBody), + } as any, + "folder", + "f/x", + "src", + "dst", + ); + expect(body.labels).toEqual([]); +}); diff --git a/cli/windmill-utils-internal/src/deploy.ts b/cli/windmill-utils-internal/src/deploy.ts index ac0b775e86..0d70e231cf 100644 --- a/cli/windmill-utils-internal/src/deploy.ts +++ b/cli/windmill-utils-internal/src/deploy.ts @@ -611,6 +611,7 @@ export async function deployItem( value: variable.value ?? "", is_secret: variable.is_secret, description: variable.description ?? "", + labels: variable.labels, }, alreadyEncrypted: false, }); @@ -622,6 +623,7 @@ export async function deployItem( value: variable.value ?? "", is_secret: variable.is_secret, description: variable.description ?? "", + labels: variable.labels, }, }); } @@ -638,6 +640,7 @@ export async function deployItem( path, value: resource.value ?? "", description: resource.description ?? "", + labels: resource.labels, }, }); } else { @@ -648,6 +651,7 @@ export async function deployItem( value: resource.value ?? "", resource_type: resource.resource_type, description: resource.description ?? "", + labels: resource.labels, }, }); } @@ -689,6 +693,9 @@ export async function deployItem( owners: folder.owners, extra_perms: folder.extra_perms, summary: folder.summary ?? undefined, + // A folder stores cleared labels as NULL and reads back without the field, so an + // absent value has to be sent as [] or the target keeps labels the source removed. + labels: folder.labels ?? [], }, }); } else { @@ -699,6 +706,7 @@ export async function deployItem( owners: folder.owners, extra_perms: folder.extra_perms, summary: folder.summary ?? undefined, + labels: folder.labels, }, }); } diff --git a/frontend/src/lib/utils_workspace_deploy.test.ts b/frontend/src/lib/utils_workspace_deploy.test.ts index f220fc93b9..dfac557d20 100644 --- a/frontend/src/lib/utils_workspace_deploy.test.ts +++ b/frontend/src/lib/utils_workspace_deploy.test.ts @@ -7,6 +7,7 @@ import { deployPermissionForKinds, kindGatedByDeployRules, checkPathWritePermission, + deployItem, diffActionableInDirection, diffCreatesInTarget, diffRemovesInTarget @@ -20,6 +21,37 @@ vi.mock('$lib/workspaceProtectionRules.svelte', async (importOriginal) => ({ fetchProtectionRulesForWorkspace: async () => rulesets })) +const variableWrites: [string, any][] = [] +vi.mock('$lib/gen', async (importOriginal) => ({ + ...((await importOriginal()) as object), + VariableService: { + existsVariable: async (p: { workspace: string }) => p.workspace === 'has-it', + getVariable: async () => ({ value: 'v', is_secret: false, labels: ['prod'] }), + createVariable: async (p: any) => void variableWrites.push(['create', p.requestBody]), + updateVariable: async (p: any) => void variableWrites.push(['update', p.requestBody]) + } +})) + +// The deploy runs the published shared package, whose variable body has no `labels`: this +// pins that the adapter still carries them until a version that does is installed. +describe('deploying a variable to another workspace', () => { + it('carries its labels on create and on update', async () => { + for (const workspaceTo of ['lacks-it', 'has-it']) { + const result = await deployItem({ + kind: 'variable', + path: 'u/a/v', + workspaceFrom: 'fork', + workspaceTo + }) + expect(result.success).toBe(true) + } + expect(variableWrites).toEqual([ + ['create', expect.objectContaining({ labels: ['prod'] })], + ['update', expect.objectContaining({ labels: ['prod'] })] + ]) + }) +}) + /** The row shape the fork comparison returns for an item the parent has and the * fork does not: one write on the fork side, whatever that write was. */ const parentOnly = { ahead: 1, behind: 0, exists_in_source: true, exists_in_fork: false } diff --git a/frontend/src/lib/utils_workspace_deploy.ts b/frontend/src/lib/utils_workspace_deploy.ts index d56c1f6541..ba1f293678 100644 --- a/frontend/src/lib/utils_workspace_deploy.ts +++ b/frontend/src/lib/utils_workspace_deploy.ts @@ -228,6 +228,18 @@ function makeProvider( if (conflict) conflict.hit = true throw new Error('item already exists in the target workspace') } + // The published package builds variable, resource and folder bodies field by field without + // `labels`, so carry the source item's across from the read `deployItem` does just before the + // write. One provider serves one deploy, so the read always belongs to the write that follows. + let sourceLabels: string[] | undefined + const readLabels = (item: T): T => { + sourceLabels = item.labels + return item + } + const withLabels = >(requestBody: T): T => ({ + ...requestBody, + labels: sourceLabels + }) return { existsFlowByPath: (p) => FlowService.existsFlowByPath(p), existsScriptByPath: (p) => ScriptService.existsScriptByPath(p), @@ -272,21 +284,31 @@ function makeProvider( getPublicSecretOfLatestVersionOfApp: (p) => AppService.getPublicSecretOfLatestVersionOfApp(p), getRawAppData: (p) => AppService.getRawAppData(p), deleteApp: (p) => AppService.deleteApp(p), - getVariable: (p) => VariableService.getVariable(p), - createVariable: (p) => VariableService.createVariable(p), - updateVariable: (p) => VariableService.updateVariable(p), + getVariable: (p) => VariableService.getVariable(p).then(readLabels), + createVariable: (p) => + VariableService.createVariable({ ...p, requestBody: withLabels(p.requestBody) }), + updateVariable: (p) => + VariableService.updateVariable({ ...p, requestBody: withLabels(p.requestBody) }), deleteVariable: (p) => VariableService.deleteVariable(p), - getResource: (p) => ResourceService.getResource(p), - createResource: (p) => ResourceService.createResource(p), - updateResource: (p) => ResourceService.updateResource(p), + getResource: (p) => ResourceService.getResource(p).then(readLabels), + createResource: (p) => + ResourceService.createResource({ ...p, requestBody: withLabels(p.requestBody) }), + updateResource: (p) => + ResourceService.updateResource({ ...p, requestBody: withLabels(p.requestBody) }), deleteResource: (p) => ResourceService.deleteResource(p), getResourceType: (p) => ResourceService.getResourceType(p), createResourceType: (p) => ResourceService.createResourceType(p), updateResourceType: (p) => ResourceService.updateResourceType(p), deleteResourceType: (p) => ResourceService.deleteResourceType(p), - getFolder: (p) => FolderService.getFolder(p), - createFolder: (p) => FolderService.createFolder(p), - updateFolder: (p) => FolderService.updateFolder(p), + getFolder: (p) => FolderService.getFolder(p).then(readLabels), + createFolder: (p) => + FolderService.createFolder({ ...p, requestBody: withLabels(p.requestBody) }), + // A folder with no labels reads back without the field, so clearing them needs an explicit []. + updateFolder: (p) => + FolderService.updateFolder({ + ...p, + requestBody: { ...p.requestBody, labels: sourceLabels ?? [] } + }), deleteFolder: (p) => FolderService.deleteFolder(p), // Triggers existsTriggerByKind: (kind, p) => triggerServiceFor(kind).exists(p), @@ -670,7 +692,7 @@ export type CreateFolderResult = DeployResult & { * principal's existence at item-create time, so the folder would be created and then reject * every deploy into it, including the retry. * - * `default_permissioned_as` and `labels` are carried at all, which the shared folder deploy drops. + * `default_permissioned_as` is carried at all, which the shared folder deploy drops. */ export async function createFolderIfAbsent( name: string, From e14da5c6bc8256bfe1d71ddecb886c3efc994ae4 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 25 Sep 2026 18:19:38 +0200 Subject: [PATCH 21/51] feat(bedrock): add OIDC role assumption as a fourth auth mode (#10936) * feat(bedrock): add OIDC role assumption as a fourth auth mode Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * fix(bedrock): gate the OIDC cache correctly and assume the role once per job Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * refactor(bedrock): check the OIDC region before minting a token Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * fix(bedrock): keep OIDC session names collision-resistant, gate the copy on EE Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * fix(bedrock): check the OIDC region before reusing cached credentials Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * fix(bedrock): clear assumed-role sessions when AI settings change invalidate_ai_request_cache_for_workspace cleared AI_REQUEST_CACHE only, so a workspace's AI settings edit reset one cache and left the assumed-role sessions keyed on the old config in place until STS expired them. Also name the region requirement in the credentials-check hint, so following it does not land on the OIDC path's region guard. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * chore: update ee-repo-ref to de73db2bacfdc3eaa2e63b1827178bc198d54e5c This commit updates the EE repository reference after PR #770 was merged in windmill-ee-private. Previous ee-repo-ref: c43dab1e69b1cb3f685e6df07bff634dc2a0b734 New ee-repo-ref: de73db2bacfdc3eaa2e63b1827178bc198d54e5c Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-ai/Cargo.toml | 2 +- backend/windmill-ai/src/ai_bedrock.rs | 258 ++++++++++++++++++ backend/windmill-ai/src/credentials.rs | 13 + .../windmill-ai/src/providers/anthropic.rs | 1 + .../windmill-ai/src/providers/google_ai.rs | 1 + backend/windmill-ai/src/providers/mod.rs | 1 + backend/windmill-ai/src/proxy.rs | 1 + backend/windmill-ai/src/types.rs | 7 + backend/windmill-api/src/ai.rs | 156 +++++++++++ backend/windmill-worker/src/ai_executor.rs | 32 ++- .../components/BedrockCredentialsCheck.svelte | 6 + 12 files changed, 475 insertions(+), 5 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 06b1b10a9a..f102167077 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -c099af43bad922fd57ed0449ab717ffa047b5546 +de73db2bacfdc3eaa2e63b1827178bc198d54e5c diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index 98ea87a4e3..b1dea3ae54 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true [features] default = [] -bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"] +bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config", "windmill-common/aws_auth"] mcp = ["dep:windmill-mcp"] [lib] diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index b223725af2..a9318a1f3a 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -2,6 +2,7 @@ //! //! This module provides: //! - BedrockClient: SDK wrapper with bearer token and IAM credentials auth +//! - OIDC role assumption: exchange a Windmill OIDC token for temporary IAM keys //! - Message/tool conversion: OpenAI format <-> Bedrock Converse API format //! - Stream event parsing: Extract text/tool deltas from Bedrock stream events //! @@ -291,6 +292,187 @@ impl BedrockClient { } } +// ============================================================================ +// OIDC Role Assumption +// ============================================================================ + +pub use windmill_common::auth::aws::AWS_OIDC_AUDIENCE; + +/// Bedrock credential precedence: a bearer API key wins, then a complete pair of +/// explicit IAM keys, then OIDC role assumption, then the ambient environment. +/// Returns the role to assume only when both higher-priority modes are unset, so +/// a resource can carry a role ARN without it ever overriding keys set on it. +pub fn bedrock_oidc_role_to_assume<'a>( + api_key: Option<&str>, + aws_access_key_id: Option<&str>, + aws_secret_access_key: Option<&str>, + oidc_role_arn: Option<&'a str>, +) -> Option<&'a str> { + let has_bearer_token = api_key.is_some_and(|key| !key.is_empty()); + let has_iam_keys = aws_access_key_id.is_some_and(|key| !key.is_empty()) + && aws_secret_access_key.is_some_and(|key| !key.is_empty()); + + if has_bearer_token || has_iam_keys { + return None; + } + + oidc_role_arn.filter(|arn| !arn.is_empty()) +} + +/// Re-assume the role this long before AWS expires the credentials, so a request +/// that starts on a reused set cannot finish holding dead ones. +pub const ASSUMED_ROLE_REFRESH_MARGIN: std::time::Duration = std::time::Duration::from_secs(120); + +/// Temporary IAM credentials minted by STS `AssumeRoleWithWebIdentity`. +#[derive(Clone, Debug)] +pub struct AssumedRoleCredentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: String, + pub expires_at: std::time::SystemTime, +} + +impl AssumedRoleCredentials { + /// Whether these have enough life left to sign another request. + pub fn is_fresh(&self) -> bool { + self.expires_at > std::time::SystemTime::now() + ASSUMED_ROLE_REFRESH_MARGIN + } +} + +/// Assume the resource's OIDC role for a job, reusing `cached` while it is still +/// fresh, and hand back the temporary keys to sign Bedrock requests with. +/// +/// The agent loop calls this once per iteration, so without the reuse a +/// tool-heavy run would mint an OIDC token and call STS on every model turn. +/// Returns `None` when a higher-priority credential is set, so the caller can +/// call it unconditionally. +pub async fn refresh_bedrock_oidc_credentials<'a>( + credentials: &crate::credentials::ProviderCredentials, + cached: &'a mut Option, + client: &windmill_common::client::AuthedClient, + job_id: &uuid::Uuid, +) -> Result, Error> { + let Some(role_arn) = bedrock_oidc_role_to_assume( + credentials.api_key.as_deref(), + credentials.aws_access_key_id.as_deref(), + credentials.aws_secret_access_key.as_deref(), + credentials.oidc_role_arn.as_deref(), + ) else { + return Ok(None); + }; + + // Ahead of the reuse check, so a request never signs against the empty region + // just because an earlier one had already assumed the role. + let region = bedrock_oidc_region(credentials.region.as_deref())?; + + if !cached + .as_ref() + .is_some_and(AssumedRoleCredentials::is_fresh) + { + // A worker holds no OIDC signing key, so it asks the API to mint the + // token for this job; the session name carries the job into CloudTrail. + let id_token = client + .get_id_token(AWS_OIDC_AUDIENCE) + .await + .map_err(|e| Error::internal_err(format!("Failed to get OIDC token: {}", e)))?; + // The whole UUID: "windmill-ai-job-" plus 32 hex is 48 characters, well + // inside AWS's limit, and a truncated one would make two runs + // indistinguishable in CloudTrail. + let session_name = aws_role_session_name("windmill-ai-job", &job_id.simple().to_string()); + *cached = + Some(assume_role_with_oidc_token(role_arn, region, id_token, &session_name).await?); + } + + Ok(cached.as_ref()) +} + +/// Distinguishing digest appended to a session name that had to be truncated. +const SESSION_NAME_DIGEST_LEN: usize = 8; + +/// Build a role session name AWS accepts: it constrains them to +/// `[\w+=,.@-]{2,64}`, and rejects the whole request otherwise. Disallowed +/// characters are replaced rather than dropped. +/// +/// The name is the only thing carrying the caller into the assumed-role ARN and +/// CloudTrail, so an identity too long to fit keeps a prefix plus a digest of the +/// whole thing: two identities sharing a prefix would otherwise be +/// indistinguishable in exactly the audit trail this feature exists to produce. +pub fn aws_role_session_name(prefix: &str, identity: &str) -> String { + let sanitize = |s: &str| -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '=' | ',' | '.' | '@' | '-') + { + c + } else { + '-' + } + }) + .collect() + }; + + let name = sanitize(&format!("{}-{}", prefix, identity)); + if name.len() <= 64 { + return name; + } + + let digest = &windmill_common::utils::calculate_hash(identity)[..SESSION_NAME_DIGEST_LEN]; + let kept = 64 - SESSION_NAME_DIGEST_LEN - 1; + format!("{}-{}", &name[..kept], digest) +} + +/// The region an OIDC role assumption runs in. +/// +/// The assumption resolves to explicit IAM keys, and the SDK client built from +/// those has no ambient-region fallback the way [`BedrockClient::from_env`] does, +/// so a resource that assumes a role has to name its region. Callers check this +/// before minting the OIDC token, so a misconfigured resource never issues an +/// identity token it is only going to throw away. +pub fn bedrock_oidc_region(region: Option<&str>) -> Result<&str, Error> { + region.filter(|r| !r.is_empty()).ok_or_else(|| { + Error::BadRequest( + "AWS Bedrock resources that assume a role through OIDC must set a region".to_string(), + ) + }) +} + +/// Exchange a Windmill OIDC token for temporary IAM credentials on `role_arn`. +pub async fn assume_role_with_oidc_token( + role_arn: &str, + region: &str, + id_token: String, + session_name: &str, +) -> Result { + use windmill_common::auth::aws::{ + get_assume_role_with_web_identity_fluent_builder, GetAuthenticationOutput, OidcAuth, + }; + + let oidc_auth = OidcAuth { region: Some(region.to_string()), role_arn: role_arn.to_string() }; + + let output = + get_assume_role_with_web_identity_fluent_builder(&oidc_auth, id_token, Some(session_name)) + .await? + .send() + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to assume AWS role {} with Windmill's OIDC token: {}", + role_arn, + format_bedrock_error(&e) + )) + })?; + + let credentials = output.get_credentials()?; + + Ok(AssumedRoleCredentials { + access_key_id: credentials.access_key_id.clone(), + secret_access_key: credentials.secret_access_key.clone(), + session_token: credentials.session_token.clone(), + expires_at: std::time::UNIX_EPOCH + + std::time::Duration::from_secs(credentials.expiration.secs().max(0) as u64), + }) +} + // ============================================================================ // Error Formatting // ============================================================================ @@ -1000,6 +1182,82 @@ mod tests { use aws_sdk_bedrockruntime::types::ReasoningContentBlock; use serde_json::value::RawValue; + #[test] + fn oidc_role_yields_only_to_bearer_and_complete_iam_keys() { + let arn = Some("arn:aws:iam::1:role/bedrock"); + + assert_eq!( + bedrock_oidc_role_to_assume(None, None, None, arn), + arn, + "nothing else set: assume the role rather than fall through to the environment" + ); + assert_eq!( + bedrock_oidc_role_to_assume(Some("key"), None, None, arn), + None + ); + assert_eq!( + bedrock_oidc_role_to_assume(None, Some("id"), Some("secret"), arn), + None + ); + assert_eq!( + bedrock_oidc_role_to_assume(None, Some("id"), None, arn), + arn, + "half a key pair is not usable IAM credentials, so the role still wins" + ); + // Cleared resource fields arrive as empty strings, not as absent ones. + assert_eq!( + bedrock_oidc_role_to_assume(Some(""), Some(""), Some(""), arn), + arn + ); + assert_eq!( + bedrock_oidc_role_to_assume(None, None, None, Some("")), + None + ); + } + + #[test] + fn credentials_stop_being_fresh_before_aws_expires_them() { + let at = |d: std::time::Duration| AssumedRoleCredentials { + access_key_id: String::new(), + secret_access_key: String::new(), + session_token: String::new(), + expires_at: std::time::SystemTime::now() + d, + }; + // Reused only while there is still margin left to finish a request. + assert!(at(ASSUMED_ROLE_REFRESH_MARGIN * 2).is_fresh()); + assert!(!at(ASSUMED_ROLE_REFRESH_MARGIN / 2).is_fresh()); + } + + #[test] + fn role_session_name_stays_within_what_aws_accepts() { + // A whole job UUID fits, so CloudTrail names the exact run. + assert_eq!( + aws_role_session_name("windmill-ai-job", "0a1b2c3d4e5f60718293a4b5c6d7e8f9"), + "windmill-ai-job-0a1b2c3d4e5f60718293a4b5c6d7e8f9" + ); + // Usernames and emails routinely carry characters AWS rejects. + assert_eq!( + aws_role_session_name("windmill-ai-copilot", "ada/lovelace (admin)"), + "windmill-ai-copilot-ada-lovelace--admin-" + ); + + // Identities too long to fit keep a digest, so a shared prefix does not + // collapse two callers into one CloudTrail identity. + let a = aws_role_session_name("windmill-ai-copilot", &format!("{}a", "x".repeat(60))); + let b = aws_role_session_name("windmill-ai-copilot", &format!("{}b", "x".repeat(60))); + assert_ne!(a, b); + for name in [&a, &b] { + assert_eq!(name.len(), 64); + assert!(name.chars().all(|c| c.is_ascii_alphanumeric() + || matches!(c, '_' | '+' | '=' | ',' | '.' | '@' | '-'))); + } + + // Sanitising before truncating keeps every char one byte, so the cap is + // never applied mid-character. + let unicode = aws_role_session_name("windmill-ai-copilot", &"é".repeat(100)); + assert_eq!(unicode.len(), 64); + } + fn text_message(role: &str, content: &str) -> OpenAIMessage { OpenAIMessage { role: role.to_string(), diff --git a/backend/windmill-ai/src/credentials.rs b/backend/windmill-ai/src/credentials.rs index c9502bdbb2..5a779938ed 100644 --- a/backend/windmill-ai/src/credentials.rs +++ b/backend/windmill-ai/src/credentials.rs @@ -19,6 +19,19 @@ pub struct ProviderCredentials { pub aws_access_key_id: Option, pub aws_secret_access_key: Option, pub aws_session_token: Option, + /// AWS IAM role to assume through Windmill's OIDC provider. Only consulted + /// when no bearer key and no explicit IAM keys are set — see + /// [`crate::ai_bedrock::bedrock_oidc_role_to_assume`]. The API proxy + /// exchanges it for temporary keys before it builds the request; the worker + /// exchanges it at the Bedrock call, because only the API can sign an OIDC + /// token. + /// + /// The two paths therefore present different tokens to AWS: the proxy a + /// workspace claim with `sub = "::"`, the worker the job + /// claim `/oidc/token` issues, `sub = "::::::"`. + /// A role trust policy that conditions on `sub` has to admit both, or only + /// one of the copilot and the AI agent step will be able to assume the role. + pub oidc_role_arn: Option, pub platform: AIPlatform, pub custom_headers: HashMap, } diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index 0bb7316dcb..d274d91fa2 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -858,6 +858,7 @@ mod tests { aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform, custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index 8320ef5661..ce37e45df7 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -780,6 +780,7 @@ mod tests { aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform, custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index af9ba3ff9f..908f007978 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -184,6 +184,7 @@ mod parity_tests { aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform: kase.platform.clone(), custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index c672aa1d42..3af8f88a1d 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -263,6 +263,7 @@ mod tests { aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index 12e06a35d8..ac65f637e2 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -245,6 +245,12 @@ pub struct ProviderResource { deserialize_with = "empty_string_as_none" )] pub aws_session_token: Option, + #[serde( + alias = "oidcRoleArn", + default, + deserialize_with = "empty_string_as_none" + )] + pub oidc_role_arn: Option, /// Platform (standard or google_vertex_ai) #[serde(default)] pub platform: AIPlatform, @@ -311,6 +317,7 @@ impl ProviderWithResource { aws_access_key_id: self.resource.aws_access_key_id.clone(), aws_secret_access_key: self.resource.aws_secret_access_key.clone(), aws_session_token: self.resource.aws_session_token.clone(), + oidc_role_arn: self.resource.oidc_role_arn.clone(), platform: self.resource.platform.clone(), custom_headers: self.resource.headers.clone(), }) diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 16b4b17141..3255b629da 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -120,8 +120,23 @@ lazy_static::lazy_static! { } +#[cfg(feature = "bedrock")] +lazy_static::lazy_static! { + /// Temporary IAM credentials from a Bedrock OIDC role assumption, keyed by + /// (workspace, role ARN, user email). The email is the identity the OIDC + /// token is minted for, so unlike AI_REQUEST_CACHE an entry is never handed + /// to a different user — that is what keeps AWS-side attribution per user. + static ref BEDROCK_ASSUMED_ROLE_CACHE: Cache<(String, String, String), windmill_ai::ai_bedrock::AssumedRoleCredentials> = Cache::new(500); +} + pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) { AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id); + // Dropped alongside the credentials that named the role, so that changing a + // workspace's AI settings does not leave a session assumed under the old ones + // in play until STS expires it. + #[cfg(feature = "bedrock")] + BEDROCK_ASSUMED_ROLE_CACHE + .retain(|(cached_workspace_id, _, _), _| cached_workspace_id != workspace_id); } /// Shared configuration for every outbound AI HTTP client (the pooled @@ -219,6 +234,12 @@ struct AIStandardResource { deserialize_with = "empty_string_as_none" )] aws_session_token: Option, + #[serde( + alias = "oidcRoleArn", + default, + deserialize_with = "empty_string_as_none" + )] + oidc_role_arn: Option, /// Platform (standard or google_vertex_ai) #[serde(default)] platform: AIPlatform, @@ -301,6 +322,11 @@ async fn resolve_provider_credentials( } else { None }; + let oidc_role_arn = if let Some(role_arn) = resource.oidc_role_arn { + Some(resolve_var(role_arn, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; Ok(ProviderCredentials { provider: provider.clone(), @@ -313,6 +339,7 @@ async fn resolve_provider_credentials( aws_access_key_id, aws_secret_access_key, aws_session_token, + oidc_role_arn, platform: resource.platform, custom_headers: resource.headers, }) @@ -337,6 +364,7 @@ async fn resolve_provider_credentials( aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), }) @@ -385,6 +413,109 @@ async fn get_token_using_oauth( Ok(response.access_token) } +/// Exchange the resource's OIDC role ARN for temporary IAM credentials, in place. +/// +/// Only the API holds the OIDC signing key, so the exchange happens here rather +/// than in the Bedrock handler, which then sees ordinary IAM keys. It runs after +/// the workspace credential cache on purpose: the STS session name names the user +/// making the request, and a cache shared by the whole workspace would hand one +/// user's session to another. +#[cfg(feature = "bedrock")] +async fn resolve_bedrock_oidc_credentials( + db: &DB, + w_id: &str, + authed: &ApiAuthed, + credentials: &mut ProviderCredentials, +) -> Result<()> { + use windmill_ai::ai_bedrock::{aws_role_session_name, bedrock_oidc_role_to_assume}; + + // Only the Bedrock resource type carries a role ARN; leaving the field set on + // any other resource inert keeps a hand-edited one from reaching STS. + if credentials.provider != AIProvider::AWSBedrock { + return Ok(()); + } + + let Some(role_arn) = bedrock_oidc_role_to_assume( + credentials.api_key.as_deref(), + credentials.aws_access_key_id.as_deref(), + credentials.aws_secret_access_key.as_deref(), + credentials.oidc_role_arn.as_deref(), + ) else { + return Ok(()); + }; + + // Ahead of the cache: STS credentials are region-independent, so a resource + // with the same role but no region would otherwise reuse an entry and sign a + // request against the empty region the guard exists to reject. + let region = windmill_ai::ai_bedrock::bedrock_oidc_region(credentials.region.as_deref())?; + + let session_name = aws_role_session_name("windmill-ai-copilot", &authed.username); + // Keyed on the email, the identity the OIDC token is minted for, rather than + // on the session name derived from it: that name is sanitised and length + // capped, so it is a lossy stand-in for the caller. + let cache_key = (w_id.to_string(), role_arn.to_string(), authed.email.clone()); + + let assumed = match BEDROCK_ASSUMED_ROLE_CACHE + .get(&cache_key) + .filter(windmill_ai::ai_bedrock::AssumedRoleCredentials::is_fresh) + { + Some(cached) => cached, + None => { + let assumed = + assume_bedrock_role(db, w_id, authed, role_arn, region, &session_name).await?; + BEDROCK_ASSUMED_ROLE_CACHE.insert(cache_key, assumed.clone()); + assumed + } + }; + + credentials.aws_access_key_id = Some(assumed.access_key_id); + credentials.aws_secret_access_key = Some(assumed.secret_access_key); + credentials.aws_session_token = Some(assumed.session_token); + + Ok(()) +} + +#[cfg(feature = "bedrock")] +async fn assume_bedrock_role( + db: &DB, + w_id: &str, + authed: &ApiAuthed, + role_arn: &str, + region: &str, + session_name: &str, +) -> Result { + #[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))] + { + use windmill_common::oidc_oss::{generate_id_token, WorkspaceClaim}; + + let id_token = generate_id_token( + Some(db), + WorkspaceClaim { workspace: w_id.to_string() }, + windmill_ai::ai_bedrock::AWS_OIDC_AUDIENCE, + format!("{}::{}", authed.email, w_id), + Some(authed.email.clone()), + None, + ) + .await?; + + windmill_ai::ai_bedrock::assume_role_with_oidc_token( + role_arn, + region, + id_token.to_string(), + session_name, + ) + .await + } + #[cfg(not(all(feature = "enterprise", feature = "openidconnect", feature = "private")))] + { + let _ = (db, w_id, authed, role_arn, region, session_name); + Err(Error::BadRequest( + "Assuming an AWS role through Windmill's OIDC provider requires an enterprise license" + .to_string(), + )) + } +} + #[derive(Clone, Debug)] pub struct ExpiringProviderCredentials { credentials: ProviderCredentials, @@ -976,6 +1107,7 @@ async fn global_proxy( aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), }; @@ -1253,6 +1385,9 @@ async fn proxy( } }; + #[cfg(feature = "bedrock")] + resolve_bedrock_oidc_credentials(&db, &w_id, &authed, &mut credentials).await?; + // Free tier: pin the model and clamp max_tokens server-side before forwarding, // since the request body is otherwise client-controlled. if free_lease.is_some() { @@ -1463,6 +1598,7 @@ mod tests { aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), } @@ -1504,6 +1640,24 @@ mod tests { ("workspace-b".to_string(), AIProvider::OpenAI), ExpiringProviderCredentials::new(sample_provider_credentials(), None), ); + #[cfg(feature = "bedrock")] + let assumed_role_key = { + let key = ( + "workspace-a".to_string(), + "arn:aws:iam::123456789012:role/bedrock".to_string(), + "someone@windmill.dev".to_string(), + ); + BEDROCK_ASSUMED_ROLE_CACHE.insert( + key.clone(), + windmill_ai::ai_bedrock::AssumedRoleCredentials { + access_key_id: "AKIA".to_string(), + secret_access_key: "secret".to_string(), + session_token: "session".to_string(), + expires_at: std::time::SystemTime::now() + std::time::Duration::from_secs(3600), + }, + ); + key + }; invalidate_ai_request_cache_for_workspace("workspace-a"); @@ -1516,6 +1670,8 @@ mod tests { assert!(AI_REQUEST_CACHE .get(&("workspace-b".to_string(), AIProvider::OpenAI)) .is_some()); + #[cfg(feature = "bedrock")] + assert!(BEDROCK_ASSUMED_ROLE_CACHE.get(&assumed_role_key).is_none()); } #[test] diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 3c3a26875b..5f2ef1e6c0 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -1419,6 +1419,11 @@ pub async fn run_agent( .map(|m| m.clamp(1, HARD_MAX_AGENT_ITERATIONS)) .unwrap_or(DEFAULT_MAX_AGENT_ITERATIONS); + // Held across iterations so an OIDC role is assumed once for the job and + // re-assumed only near expiry, rather than on every model turn. + #[cfg(feature = "bedrock")] + let mut bedrock_assumed_role: Option = None; + // Main agent loop for i in 0..max_iterations { // Check if parent was canceled — stop iterating but let current tool calls finish @@ -1438,6 +1443,27 @@ pub async fn run_agent( .region .as_deref() .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION); + // An OIDC role resolves to ordinary IAM keys, so from here the + // call is identical to the explicit-keys mode. + let assumed = windmill_ai::ai_bedrock::refresh_bedrock_oidc_credentials( + &credentials, + &mut bedrock_assumed_role, + client, + &job.id, + ) + .await?; + let (access_key_id, secret_access_key, session_token) = match assumed { + Some(assumed) => ( + Some(assumed.access_key_id.as_str()), + Some(assumed.secret_access_key.as_str()), + Some(assumed.session_token.as_str()), + ), + None => ( + credentials.aws_access_key_id.as_deref(), + credentials.aws_secret_access_key.as_deref(), + credentials.aws_session_token.as_deref(), + ), + }; // Use Bedrock SDK via dedicated query builder windmill_ai::providers::bedrock::BedrockQueryBuilder::default() .execute_request( @@ -1453,9 +1479,9 @@ pub async fn run_agent( client, &job.workspace_id, structured_output_tool_name.as_deref(), - credentials.aws_access_key_id.as_deref(), - credentials.aws_secret_access_key.as_deref(), - credentials.aws_session_token.as_deref(), + access_key_id, + secret_access_key, + session_token, ) .await? } diff --git a/frontend/src/lib/components/BedrockCredentialsCheck.svelte b/frontend/src/lib/components/BedrockCredentialsCheck.svelte index fa39309a50..726c6d8f0a 100644 --- a/frontend/src/lib/components/BedrockCredentialsCheck.svelte +++ b/frontend/src/lib/components/BedrockCredentialsCheck.svelte @@ -1,5 +1,6 @@ - - drawer?.closeDrawer()}> - {#if activeDrawer} - {#key activeDrawer.key} - {#await activeDrawer.promise} -
- -
- {:then Module} - {@const Editor = Module.default as EditorComponent} - - {/await} - {/key} - {/if} -
-
- - - +{#if inner} + {#await inner then Inner} + + {/await} +{/if} diff --git a/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawersInner.svelte b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawersInner.svelte new file mode 100644 index 0000000000..9dd8fe643c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawersInner.svelte @@ -0,0 +1,192 @@ + + + + drawer?.closeDrawer()}> + {#if activeDrawer} + {#key activeDrawer.key} + {#await activeDrawer.promise} +
+ +
+ {:then Module} + {@const Editor = Module.default as EditorComponent} + + {/await} + {/key} + {/if} +
+
+ + + diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 1e486c7d41..6df8b68518 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -60,7 +60,7 @@ import { logFeatureUsage, logHubScriptPick } from '$lib/utils/featureUsage' import { forLater } from '$lib/forLater' import { scriptLangToEditorLang } from '$lib/scripts' import { getCurrentModel } from '$lib/aiStore' -import { type editor as meditor } from 'monaco-editor' +import type { editor as meditor } from 'monaco-editor' import { pendingFolderInstructions, type FolderInstructionsContext } from './folderInstructions' // Prettify function for code arguments - extracts and formats code from JSON diff --git a/frontend/src/lib/components/copilot/shared.ts b/frontend/src/lib/components/copilot/shared.ts index ed37052298..1cdfc239ad 100644 --- a/frontend/src/lib/components/copilot/shared.ts +++ b/frontend/src/lib/components/copilot/shared.ts @@ -1,5 +1,5 @@ import { createLongHash } from '$lib/editorLangUtils' -import { type editor as meditor } from 'monaco-editor' +import type { editor as meditor } from 'monaco-editor' export type VisualChange = | { diff --git a/frontend/src/lib/components/flows/pickers/PickHubScript.svelte b/frontend/src/lib/components/flows/pickers/PickHubScript.svelte index 2e63266a58..5d3de9150b 100644 --- a/frontend/src/lib/components/flows/pickers/PickHubScript.svelte +++ b/frontend/src/lib/components/flows/pickers/PickHubScript.svelte @@ -3,7 +3,7 @@ import { Alert, Badge, ButtonType, Skeleton } from '$lib/components/common' import { capitalize } from '$lib/utils' import NoItemFound from '$lib/components/home/NoItemFound.svelte' - import { APP_TO_ICON_COMPONENT } from '$lib/components/icons' + import { appIconMap } from '$lib/components/icons/appIcon.svelte' import { listHubIntegrationsShared } from '$lib/components/displayNameLoaders' import ListFilters from '$lib/components/home/ListFilters.svelte' import { ScriptService, type HubScriptKind } from '$lib/gen' @@ -205,8 +205,8 @@ >
- {#if item['app'] in APP_TO_ICON_COMPONENT} - {@const SvelteComponent = APP_TO_ICON_COMPONENT[item['app']]} + {#if appIconMap()?.[item['app']]} + {@const SvelteComponent = appIconMap()![item['app']]} {/if}
diff --git a/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte b/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte index 6648c622c0..e1cfa1d972 100644 --- a/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte +++ b/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte @@ -36,7 +36,7 @@ import { createEventDispatcher, getContext, untrack } from 'svelte' import { Skeleton } from '$lib/components/common' import { classNames, createCache } from '$lib/utils' - import { APP_TO_ICON_COMPONENT } from '$lib/components/icons' + import { appIconMap } from '$lib/components/icons/appIcon.svelte' import { listHubIntegrationsShared } from '$lib/components/displayNameLoaders' import { ScriptService, type HubScriptKind } from '$lib/gen' import { Circle, ExternalLink } from 'lucide-svelte' @@ -253,8 +253,8 @@ onClick={() => handlePickScript(item)} >
- {#if item['app'] in APP_TO_ICON_COMPONENT} - {@const SvelteComponent = APP_TO_ICON_COMPONENT[item['app']]} + {#if appIconMap()?.[item['app']]} + {@const SvelteComponent = appIconMap()![item['app']]} {:else}
diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 3c870832ee..0f833084c7 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -52,7 +52,6 @@ import TriggersNode from './renderers/nodes/TriggersNode.svelte' import { Alert, Drawer } from '../common' import Button from '../common/button/Button.svelte' - import FlowYamlEditor from '../flows/header/FlowYamlEditor.svelte' import BranchOneEndNode from './renderers/nodes/branchOneEndNode.svelte' import type { TriggerContext } from '../triggers' import SubflowBound from './renderers/nodes/SubflowBound.svelte' @@ -1296,7 +1295,10 @@ {#if insertable} - + + {#await import('../flows/header/FlowYamlEditor.svelte') then FlowYamlEditor} + + {/await} {/if} {#if canUseDiffDrawer} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index f86ec9d5bf..1d9edc683b 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -41,7 +41,6 @@ import NoItemFound from './NoItemFound.svelte' import WorkspaceEmptyState from './WorkspaceEmptyState.svelte' import HubProjectPickerModal from './HubProjectPickerModal.svelte' - import ImportProjectModal from './ImportProjectModal.svelte' import type { HubProjectPick } from '$lib/hubProject' import ListFilters from './ListFilters.svelte' import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' @@ -1009,6 +1008,11 @@ // the create menu's Import section open the same dialog, and mounting one per entry point // would put two of them on the page at once while the workspace is still empty. let hubPick = $state(undefined) + // Sticky, so the import modal mounts on the first pick and then stays for its close animation. + let hubImportRequested = $state(false) + $effect(() => { + if (hubPick) hubImportRequested = true + }) let hubPickerOpen = $state(false) /** @@ -2077,7 +2081,13 @@ hubPick = project }} /> - (hubPick = undefined)} {onImported} /> + +{#if hubImportRequested} + {#await import('./ImportProjectModal.svelte') then ImportProjectModal} + (hubPick = undefined)} {onImported} /> + {/await} +{/if}