From d8b4132b9ae90af759c6655f4f69479f6738e60a Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 11 Mar 2026 23:39:15 +0100 Subject: [PATCH 01/76] fix: native mode now properly sets DB pool size and sleep queue (#8332) Native mode (from DB config) was not accounted for when sizing the connection pool or setting SLEEP_QUEUE, because both read NUM_WORKERS from env which is never set when native mode is configured via the worker group config in the database. - Resolve native mode early (before connect_db) by querying the config table with the initial DB connection - Pass num_workers directly to connect_db instead of re-reading env - Replace SLEEP_QUEUE lazy_static with sleep_queue() function that checks NATIVE_MODE_RESOLVED at runtime (returns 300ms for native) - Set NATIVE_MODE_RESOLVED immediately when is_native_mode_from_env() - Allow native_mode in CE worker group config (was silently stripped) Co-authored-by: Claude Opus 4.6 --- ...29980a0bf4970b919cabad1daab16627404fc.json | 22 +++++++++++ backend/src/db_connect.rs | 9 +---- backend/src/main.rs | 38 +++++++++++++------ backend/src/monitor.rs | 3 +- backend/windmill-api-configs/src/lib.rs | 5 ++- backend/windmill-worker/src/worker.rs | 18 ++++++--- 6 files changed, 68 insertions(+), 27 deletions(-) create mode 100644 backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json diff --git a/backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json b/backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json new file mode 100644 index 0000000000..e625a747f7 --- /dev/null +++ b/backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (config->>'native_mode')::boolean FROM config WHERE name = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bool", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc" +} diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index 05bf7b6522..d0f8f3af44 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -20,6 +20,7 @@ pub async fn connect_db( server_mode: bool, indexer_mode: bool, worker_mode: bool, + num_workers: i32, #[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>, ) -> anyhow::Result> { use anyhow::Context; @@ -34,13 +35,7 @@ pub async fn connect_db( } else if indexer_mode { DEFAULT_MAX_CONNECTIONS_INDEXER } else { - DEFAULT_MAX_CONNECTIONS_WORKER - + std::env::var("NUM_WORKERS") - .ok() - .map(|x| x.parse().ok()) - .flatten() - .unwrap_or(1) - - 1 + DEFAULT_MAX_CONNECTIONS_WORKER + (num_workers.max(1) as u32) - 1 } } }; diff --git a/backend/src/main.rs b/backend/src/main.rs index 45849c0bd3..4a58ce0fde 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -694,6 +694,7 @@ async fn windmill_main() -> anyhow::Result<()> { let mut num_workers = if mode == Mode::Server || mode == Mode::Indexer || mode == Mode::MCP { 0 } else if is_native_mode_from_env() { + NATIVE_MODE_RESOLVED.store(true, std::sync::atomic::Ordering::Relaxed); println!("Native mode enabled: forcing NUM_WORKERS=8"); 8 } else { @@ -866,6 +867,30 @@ async fn windmill_main() -> anyhow::Result<()> { } } + // Resolve native mode early (before connect_db) so connection pool size accounts for it. + // native_mode can come from env OR from the DB worker group config. + if worker_mode && !is_native_mode_from_env() { + if let Some(db) = conn.as_sql() { + let native_from_db: bool = sqlx::query_scalar!( + "SELECT (config->>'native_mode')::boolean FROM config WHERE name = $1", + format!("worker__{}", *windmill_common::worker::WORKER_GROUP) + ) + .fetch_optional(db) + .await + .ok() + .flatten() + .flatten() + .unwrap_or(false); + if native_from_db { + NATIVE_MODE_RESOLVED.store(true, std::sync::atomic::Ordering::Relaxed); + num_workers = 8; + tracing::info!( + "Native mode detected from worker config (early): forcing NUM_WORKERS=8" + ); + } + } + } + let conn = if mode == Mode::Agent { conn } else { @@ -878,6 +903,7 @@ async fn windmill_main() -> anyhow::Result<()> { server_mode, indexer_mode, worker_mode, + num_workers, #[cfg(feature = "private")] killpill_rx.resubscribe(), ) @@ -982,16 +1008,6 @@ Windmill Community Edition {GIT_VERSION} ) .await; - // native_mode may also be set via DB worker group config (not just env). - // NATIVE_MODE_RESOLVED is updated by load_worker_config during initial_load. - if worker_mode - && !is_native_mode_from_env() - && NATIVE_MODE_RESOLVED.load(std::sync::atomic::Ordering::Relaxed) - { - num_workers = 8; - tracing::info!("Native mode detected from worker config: forcing NUM_WORKERS=8"); - } - monitor_db( &conn, &base_internal_url, @@ -1884,7 +1900,7 @@ pub async fn run_workers( tracing::info!( "Starting {num_workers} workers and SLEEP_QUEUE={}ms", - *windmill_worker::SLEEP_QUEUE + windmill_worker::sleep_queue() ); for i in 1..(num_workers + 1) { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index d0b26a6b50..803afabf0d 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -251,9 +251,8 @@ pub async fn initial_load( .map(|x| x.tags.clone()) .unwrap_or_default(); // we only check from env as native_mode is not stored in the token + // NATIVE_MODE_RESOLVED is already set in main.rs during startup let native_mode = windmill_common::worker::is_native_mode_from_env(); - windmill_common::worker::NATIVE_MODE_RESOLVED - .store(native_mode, std::sync::atomic::Ordering::Relaxed); *config = WorkerConfig { worker_tags, env_vars: load_env_vars( diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs index f4e91e554f..a05f2dbaa8 100644 --- a/backend/windmill-api-configs/src/lib.rs +++ b/backend/windmill-api-configs/src/lib.rs @@ -129,11 +129,12 @@ async fn update_config( #[cfg(not(feature = "enterprise"))] let config = if name.starts_with("worker__") { - // In CE, only allow setting worker_tags, cache_clear, and init_bash + // In CE, only allow setting worker_tags, cache_clear, init_bash, and native_mode serde_json::json!({ "worker_tags": config.get("worker_tags"), "cache_clear": config.get("cache_clear"), - "init_bash": config.get("init_bash") + "init_bash": config.get("init_bash"), + "native_mode": config.get("native_mode") }) } else { config diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 3ea0dd0f6b..2a9026b82e 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -303,7 +303,7 @@ pub struct PowershellRepo { lazy_static::lazy_static! { - pub static ref SLEEP_QUEUE: u64 = std::env::var("SLEEP_QUEUE") + static ref SLEEP_QUEUE_BASE: u64 = std::env::var("SLEEP_QUEUE") .ok() .and_then(|x| x.parse::().ok()) .unwrap_or_else(|| { @@ -647,6 +647,14 @@ lazy_static::lazy_static! { pub static ref FLOW_RUNNER_RUNNING: Mutex = Mutex::new(false); } +pub fn sleep_queue() -> u64 { + if NATIVE_MODE_RESOLVED.load(std::sync::atomic::Ordering::Relaxed) { + 300 + } else { + *SLEEP_QUEUE_BASE + } +} + type Envs = Vec<(String, String)>; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1373,7 +1381,7 @@ fn start_interactive_worker_shell( { Duration::from_secs(WORKER_SHELL_NAP_TIME_DURATION) } - _ => Duration::from_millis(*SLEEP_QUEUE * 10), + _ => Duration::from_millis(sleep_queue() * 10), }; tokio::select! { _ = tokio::time::sleep(nap_time) => { @@ -1386,7 +1394,7 @@ fn start_interactive_worker_shell( Err(err) => { tracing::error!(worker = %worker_name, hostname = %hostname, "Failed to pull jobs: {}", err); - tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 20)).await; + tokio::time::sleep(Duration::from_millis(sleep_queue() * 20)).await; } }; } @@ -2699,7 +2707,7 @@ pub async fn run_worker( None }; - tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE)).await; + tokio::time::sleep(Duration::from_millis(sleep_queue())).await; #[cfg(feature = "benchmark")] { @@ -2720,7 +2728,7 @@ pub async fn run_worker( } Err(err) => { tracing::error!(worker = %worker_name, hostname = %hostname, "Failed to pull jobs: {}", err); - tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 5)).await; + tokio::time::sleep(Duration::from_millis(sleep_queue() * 5)).await; } }; } From cbc7e78f8a60bff1d8730a6183cdbc9125d8e2b1 Mon Sep 17 00:00:00 2001 From: Clumsy <59402569+ClumsyAdmin@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:42:15 -0400 Subject: [PATCH 02/76] fix: show diff editor content for resources without a language (#8331) Resources like ansible_inventory have content but no language field, causing the DiffEditor setupModel guard to skip initialization entirely. Fall back to 'plaintext' when no language is provided but content exists. Co-authored-by: Claude Opus 4.6 --- frontend/src/lib/components/DiffEditor.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/DiffEditor.svelte b/frontend/src/lib/components/DiffEditor.svelte index 1d252d082a..3fb26334b9 100644 --- a/frontend/src/lib/components/DiffEditor.svelte +++ b/frontend/src/lib/components/DiffEditor.svelte @@ -102,8 +102,8 @@ }) } - if (defaultLang !== undefined) { - setupModel(defaultLang, defaultOriginal, defaultModified, defaultModifiedLang) + if (defaultLang !== undefined || defaultOriginal !== undefined || defaultModified !== undefined) { + setupModel(defaultLang ?? 'plaintext', defaultOriginal, defaultModified, defaultModifiedLang) } } From bf4340f40c1eb9cacee4c32e07ba44f2c92bf7c4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 11 Mar 2026 23:46:24 +0000 Subject: [PATCH 03/76] fix: set min_connections(0) to prevent sqlx pool spin loop (#8334) Co-authored-by: Claude Opus 4.5 --- backend/src/db_connect.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index d0f8f3af44..abb3378efc 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -98,7 +98,7 @@ pub async fn connect( use sqlx::Executor; use std::time::Duration; let mut pool_options = sqlx::postgres::PgPoolOptions::new() - .min_connections((max_connections / 5).clamp(1, max_connections)) + .min_connections(0) .max_connections(max_connections) .max_lifetime(Duration::from_secs(30 * 60)); // 30 mins if worker_mode { From ca8a6274bc81ad49fa0c6166694ae4d65a4048cb Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:15:00 +0100 Subject: [PATCH 04/76] fix: use !inline ref for scripts inside flows (preproc, error, ai tool) (#8319) * fix: use !inline ref for scripts inside flows (preproc, error, ai tool) * add test * path assign better --- cli/src/commands/flow/flow.ts | 18 +- cli/src/commands/flow/flow_metadata.ts | 30 +- cli/src/commands/sync/sync.ts | 23 +- ...nline_scripts_failure_preprocessor.test.ts | 498 ++++++++++++++++++ .../src/inline-scripts/extractor.ts | 11 +- 5 files changed, 573 insertions(+), 7 deletions(-) create mode 100644 cli/test/inline_scripts_failure_preprocessor.test.ts diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index eff742ee68..f0974cf354 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -56,13 +56,20 @@ export async function pushFlow( } const localFlow = (await yamlParseFile(localPath + "flow.yaml")) as FlowFile; + const fileReader = async (path: string) => await readFile(localPath + path, "utf-8"); await replaceInlineScripts( localFlow.value.modules, - async (path: string) => await readFile(localPath + path, "utf-8"), + fileReader, log, localPath, SEP ); + if (localFlow.value.failure_module) { + await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, localPath, SEP); + } + if (localFlow.value.preprocessor_module) { + await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, localPath, SEP); + } if (flow) { if (isSuperset(localFlow, flow)) { @@ -252,13 +259,20 @@ async function preview( const localFlow = (await yamlParseFile(flowPath + "flow.yaml")) as FlowFile; // Replace inline scripts with their actual content + const fileReader = async (path: string) => await readFile(flowPath + path, "utf-8"); await replaceInlineScripts( localFlow.value.modules, - async (path: string) => await readFile(flowPath + path, "utf-8"), + fileReader, log, flowPath, SEP ); + if (localFlow.value.failure_module) { + await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, flowPath, SEP); + } + if (localFlow.value.preprocessor_module) { + await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, flowPath, SEP); + } const input = opts.data ? await resolve(opts.data) : {}; diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index cb2e5336e6..d52d05a3ed 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -19,6 +19,7 @@ import { } from "../../utils/metadata.ts"; import { ScriptLanguage } from "../../utils/script_common.ts"; import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { newPathAssigner } from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts"; import { generateHash, getHeaders, writeIfChanged } from "../../utils/utils.ts"; import { exts } from "../script/script.ts"; @@ -121,14 +122,21 @@ export async function generateFlowLockInternal( } log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); + const fileReader = async (path: string) => await readFile(folder + SEP + path, "utf-8"); await replaceInlineScripts( flowValue.value.modules, - async (path: string) => await readFile(folder + SEP + path, "utf-8"), + fileReader, log, folder + SEP!, SEP, changedScripts ); + if (flowValue.value.failure_module) { + await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, changedScripts); + } + if (flowValue.value.preprocessor_module) { + await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, changedScripts); + } //removeChangedLocks flowValue.value = await updateFlow( @@ -138,12 +146,20 @@ export async function generateFlowLockInternal( filteredDeps ); + const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun"); const inlineScripts = extractInlineScriptsForFlows( flowValue.value.modules, {}, SEP, - opts.defaultTs + opts.defaultTs, + lockAssigner ); + if (flowValue.value.failure_module) { + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], {}, SEP, opts.defaultTs, lockAssigner)); + } + if (flowValue.value.preprocessor_module) { + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], {}, SEP, opts.defaultTs, lockAssigner)); + } inlineScripts.forEach((s) => { writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content); }); @@ -176,7 +192,15 @@ async function filterWorkspaceDependenciesForFlow( rawWorkspaceDependencies: Record, folder: string ): Promise> { - const inlineScripts = extractInlineScriptsForFlows(structuredClone(flowValue.modules), {}, SEP, undefined); + const clonedValue = structuredClone(flowValue); + const depAssigner = newPathAssigner("bun"); + const inlineScripts = extractInlineScriptsForFlows(clonedValue.modules, {}, SEP, undefined, depAssigner); + if (clonedValue.failure_module) { + inlineScripts.push(...extractInlineScriptsForFlows([clonedValue.failure_module], {}, SEP, undefined, depAssigner)); + } + if (clonedValue.preprocessor_module) { + inlineScripts.push(...extractInlineScriptsForFlows([clonedValue.preprocessor_module], {}, SEP, undefined, depAssigner)); + } // Filter out lock files and map to common interface const scripts = inlineScripts diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 7d48a14848..6375a55ada 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -592,14 +592,35 @@ function ZipFSElement( } let inlineScripts; try { + const assigner = newPathAssigner(defaultTs, { skipInlineScriptSuffix: getNonDottedPaths() }); inlineScripts = extractInlineScriptsForFlows( flow.value.modules as any, {}, SEP, defaultTs, - undefined, // pathAssigner - let it create one + assigner, { skipInlineScriptSuffix: getNonDottedPaths() }, ); + if (flow.value.failure_module) { + inlineScripts.push(...extractInlineScriptsForFlows( + [flow.value.failure_module], + {}, + SEP, + defaultTs, + assigner, + { skipInlineScriptSuffix: getNonDottedPaths() }, + )); + } + if (flow.value.preprocessor_module) { + inlineScripts.push(...extractInlineScriptsForFlows( + [flow.value.preprocessor_module], + {}, + SEP, + defaultTs, + assigner, + { skipInlineScriptSuffix: getNonDottedPaths() }, + )); + } } catch (error) { log.error( `Failed to extract inline scripts for flow at path: ${p}`, diff --git a/cli/test/inline_scripts_failure_preprocessor.test.ts b/cli/test/inline_scripts_failure_preprocessor.test.ts new file mode 100644 index 0000000000..1a9150a257 --- /dev/null +++ b/cli/test/inline_scripts_failure_preprocessor.test.ts @@ -0,0 +1,498 @@ +/** + * Unit tests for failure_module and preprocessor_module inline script + * extraction (pull) and replacement (push). + * + * These tests verify that rawscript content in failure_module and + * preprocessor_module is correctly extracted to !inline references + * and resolved back, matching the existing behavior for regular modules. + */ + +import { expect, test, describe } from "bun:test"; +import { extractInlineScripts, extractCurrentMapping } from "../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { replaceInlineScripts } from "../windmill-utils-internal/src/inline-scripts/replacer.ts"; +import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path-assigner.ts"; +import type { FlowModule } from "../windmill-utils-internal/src/gen/types.gen.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRawscriptModule( + id: string, + content: string, + language: "bun" | "python3" | "deno" = "bun", + lock?: string, +): FlowModule { + return { + id, + value: { + type: "rawscript" as const, + content, + language, + lock: lock, + input_transforms: {}, + }, + }; +} + +const noopLogger = { + info: () => {}, + error: () => {}, +}; + +// --------------------------------------------------------------------------- +// extractInlineScripts — PULL direction +// --------------------------------------------------------------------------- + +describe("extractInlineScripts for failure_module / preprocessor_module", () => { + test("extracts rawscript from failure_module wrapped in array", () => { + const failureModule = makeRawscriptModule( + "failure", + 'export function main() { throw new Error("handler"); }', + "bun", + ); + + const scripts = extractInlineScripts([failureModule], {}, "/", "bun"); + + expect(scripts.length).toBeGreaterThanOrEqual(1); + const script = scripts.find((s) => !s.is_lock); + expect(script).toBeDefined(); + expect(script!.content).toBe( + 'export function main() { throw new Error("handler"); }', + ); + // The module content should have been replaced with an !inline reference + expect(failureModule.value.content).toStartWith("!inline "); + }); + + test("extracts rawscript from preprocessor_module wrapped in array", () => { + const preprocessorModule = makeRawscriptModule( + "preprocessor", + "export function main() { return {}; }", + "python3", + ); + + const scripts = extractInlineScripts( + [preprocessorModule], + {}, + "/", + "bun", + ); + + expect(scripts.length).toBeGreaterThanOrEqual(1); + const script = scripts.find((s) => !s.is_lock); + expect(script).toBeDefined(); + expect(script!.content).toBe("export function main() { return {}; }"); + expect(script!.language).toBe("python3"); + expect(preprocessorModule.value.content).toStartWith("!inline "); + }); + + test("extracts lock alongside content", () => { + const mod = makeRawscriptModule( + "failure", + "console.log('hi')", + "bun", + "some-lock-content", + ); + + const scripts = extractInlineScripts([mod], {}, "/", "bun"); + + const contentScript = scripts.find((s) => !s.is_lock); + const lockScript = scripts.find((s) => s.is_lock); + expect(contentScript).toBeDefined(); + expect(lockScript).toBeDefined(); + expect(lockScript!.content).toBe("some-lock-content"); + expect((mod.value as any).lock).toStartWith("!inline "); + }); + + test("shared pathAssigner prevents collisions when summaries match", () => { + // If a regular module and failure_module share the same summary, + // a shared PathAssigner deduplicates via its internal counter. + const regular = makeRawscriptModule("a", "code_a", "bun"); + regular.summary = "my step"; + const failure = makeRawscriptModule("failure", "code_failure", "bun"); + failure.summary = "my step"; // same summary — would collide without shared assigner + + const assigner = newPathAssigner("bun"); + const scripts1 = extractInlineScripts([regular], {}, "/", "bun", assigner); + const scripts2 = extractInlineScripts([failure], {}, "/", "bun", assigner); + + const allPaths = [...scripts1, ...scripts2] + .filter((s) => !s.is_lock) + .map((s) => s.path); + + // All paths should be unique despite identical summaries + expect(allPaths.length).toBe(2); + expect(new Set(allPaths).size).toBe(2); + }); + + test("without shared pathAssigner, identical summaries produce duplicate paths", () => { + // Demonstrates the problem that sharing a PathAssigner solves. + const regular = makeRawscriptModule("a", "code_a", "bun"); + regular.summary = "my step"; + const failure = makeRawscriptModule("failure", "code_failure", "bun"); + failure.summary = "my step"; + + // Separate assigners — each starts with a fresh counter + const scripts1 = extractInlineScripts([regular], {}, "/", "bun"); + const scripts2 = extractInlineScripts([failure], {}, "/", "bun"); + + const allPaths = [...scripts1, ...scripts2] + .filter((s) => !s.is_lock) + .map((s) => s.path); + + // Without a shared assigner, the paths collide + expect(allPaths.length).toBe(2); + expect(new Set(allPaths).size).toBe(1); // both got the same path + }); + + test("skips non-rawscript failure_module (identity type)", () => { + const identityModule: FlowModule = { + id: "failure", + value: { type: "identity" as any }, + }; + const scripts = extractInlineScripts([identityModule], {}, "/", "bun"); + expect(scripts).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// replaceInlineScripts — PUSH direction +// --------------------------------------------------------------------------- + +describe("replaceInlineScripts for failure_module / preprocessor_module", () => { + test("resolves !inline reference back to file content", async () => { + const failureModule = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + ); + + const files: Record = { + "failure.inline_script.ts": 'export function main() { return "error handled"; }', + }; + + await replaceInlineScripts( + [failureModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(failureModule.value.content).toBe( + 'export function main() { return "error handled"; }', + ); + }); + + test("resolves !inline reference for preprocessor_module", async () => { + const preprocessorModule = makeRawscriptModule( + "preprocessor", + "!inline preprocessor.inline_script.py", + "python3", + ); + + const files: Record = { + "preprocessor.inline_script.py": "def main(): return {}", + }; + + await replaceInlineScripts( + [preprocessorModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(preprocessorModule.value.content).toBe("def main(): return {}"); + }); + + test("resolves !inline lock reference", async () => { + const mod = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + "!inline failure.inline_script.lock", + ); + + const files: Record = { + "failure.inline_script.ts": "code here", + "failure.inline_script.lock": "lock-data-here", + }; + + await replaceInlineScripts( + [mod], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(mod.value.content).toBe("code here"); + expect((mod.value as any).lock).toBe("lock-data-here"); + }); + + test("leaves non-inline content untouched", async () => { + const mod = makeRawscriptModule( + "failure", + "export function main() { return 1; }", + "bun", + ); + + await replaceInlineScripts( + [mod], + async () => { + throw new Error("should not be called"); + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(mod.value.content).toBe("export function main() { return 1; }"); + }); +}); + +// --------------------------------------------------------------------------- +// Round-trip: extract then replace +// --------------------------------------------------------------------------- + +describe("round-trip extract → replace for failure_module / preprocessor_module", () => { + test("failure_module content survives extract + replace", async () => { + const originalContent = 'export function main(error: any) {\n console.error(error);\n return { handled: true };\n}'; + const failureModule = makeRawscriptModule( + "failure", + originalContent, + "bun", + ); + + // PULL: extract inline scripts (mutates module in place) + const extracted = extractInlineScripts([failureModule], {}, "/", "bun"); + expect(failureModule.value.content).toStartWith("!inline "); + + // Build a virtual filesystem from extracted scripts + const files: Record = {}; + for (const s of extracted) { + files[s.path] = s.content; + } + + // PUSH: replace inline references back + await replaceInlineScripts( + [failureModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(failureModule.value.content).toBe(originalContent); + }); + + test("preprocessor_module content survives extract + replace", async () => { + const originalContent = "def main():\n return {\"preprocessed\": True}"; + const preprocessorModule = makeRawscriptModule( + "preprocessor", + originalContent, + "python3", + ); + + const extracted = extractInlineScripts( + [preprocessorModule], + {}, + "/", + "bun", + ); + expect(preprocessorModule.value.content).toStartWith("!inline "); + + const files: Record = {}; + for (const s of extracted) { + files[s.path] = s.content; + } + + await replaceInlineScripts( + [preprocessorModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(preprocessorModule.value.content).toBe(originalContent); + }); + + test("failure_module with lock survives extract + replace", async () => { + const originalContent = "export function main() { return 42; }"; + const originalLock = "package-lock-contents-here"; + const mod = makeRawscriptModule( + "failure", + originalContent, + "bun", + originalLock, + ); + + const extracted = extractInlineScripts([mod], {}, "/", "bun"); + + const files: Record = {}; + for (const s of extracted) { + files[s.path] = s.content; + } + + await replaceInlineScripts( + [mod], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(mod.value.content).toBe(originalContent); + expect((mod.value as any).lock).toBe(originalLock); + }); + + test("full flow with modules + failure_module + preprocessor_module round-trips", async () => { + const regularContent = "export function main() { return 'step1'; }"; + const failureContent = "export function main(e: any) { return e; }"; + const preprocessorContent = "def main():\n pass"; + + const modules = [makeRawscriptModule("a", regularContent, "bun")]; + const failureModule = makeRawscriptModule("failure", failureContent, "bun"); + const preprocessorModule = makeRawscriptModule("preprocessor", preprocessorContent, "python3"); + + // Extract all (mimicking sync.ts pull logic) + const allExtracted = [ + ...extractInlineScripts(modules, {}, "/", "bun"), + ...extractInlineScripts([failureModule], {}, "/", "bun"), + ...extractInlineScripts([preprocessorModule], {}, "/", "bun"), + ]; + + // All modules should now have !inline references + expect(modules[0].value.content).toStartWith("!inline "); + expect(failureModule.value.content).toStartWith("!inline "); + expect(preprocessorModule.value.content).toStartWith("!inline "); + + // All paths should be unique + const paths = allExtracted.filter((s) => !s.is_lock).map((s) => s.path); + expect(new Set(paths).size).toBe(paths.length); + + // Build filesystem + const files: Record = {}; + for (const s of allExtracted) { + files[s.path] = s.content; + } + + const fileReader = async (path: string) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }; + + // Replace all (mimicking flow.ts push logic) + await replaceInlineScripts(modules, fileReader, noopLogger, "/tmp/", "/"); + await replaceInlineScripts([failureModule], fileReader, noopLogger, "/tmp/", "/"); + await replaceInlineScripts([preprocessorModule], fileReader, noopLogger, "/tmp/", "/"); + + expect(modules[0].value.content).toBe(regularContent); + expect(failureModule.value.content).toBe(failureContent); + expect(preprocessorModule.value.content).toBe(preprocessorContent); + }); +}); + +// --------------------------------------------------------------------------- +// extractCurrentMapping +// --------------------------------------------------------------------------- + +describe("extractCurrentMapping for failure_module / preprocessor_module", () => { + test("extracts mapping from failure_module via optional param", () => { + const failureModule: FlowModule = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + ); + + const mapping = extractCurrentMapping( + undefined, + {}, + failureModule, + undefined, + ); + + expect(mapping["failure"]).toBe("failure.inline_script.ts"); + }); + + test("extracts mapping from preprocessor_module via optional param", () => { + const preprocessorModule: FlowModule = makeRawscriptModule( + "preprocessor", + "!inline preprocessor.inline_script.py", + "python3", + ); + + const mapping = extractCurrentMapping( + undefined, + {}, + undefined, + preprocessorModule, + ); + + expect(mapping["preprocessor"]).toBe("preprocessor.inline_script.py"); + }); + + test("extracts mapping from modules + failure + preprocessor combined", () => { + const modules: FlowModule[] = [ + makeRawscriptModule("a", "!inline a.inline_script.ts", "bun"), + ]; + const failureModule = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + ); + const preprocessorModule = makeRawscriptModule( + "preprocessor", + "!inline preprocessor.inline_script.py", + "python3", + ); + + const mapping = extractCurrentMapping( + modules, + {}, + failureModule, + preprocessorModule, + ); + + expect(mapping["a"]).toBe("a.inline_script.ts"); + expect(mapping["failure"]).toBe("failure.inline_script.ts"); + expect(mapping["preprocessor"]).toBe("preprocessor.inline_script.py"); + }); + + test("ignores non-inline content in failure_module", () => { + const failureModule = makeRawscriptModule( + "failure", + "export function main() {}", + "bun", + ); + + const mapping = extractCurrentMapping( + undefined, + {}, + failureModule, + undefined, + ); + + expect(mapping["failure"]).toBeUndefined(); + }); +}); diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index df9a1db08b..a3572ce7eb 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -150,8 +150,17 @@ export function extractInlineScripts( */ export function extractCurrentMapping( modules: FlowModule[] | undefined, - mapping: Record = {} + mapping: Record = {}, + failureModule?: FlowModule, + preprocessorModule?: FlowModule, ): Record { + if (failureModule) { + extractCurrentMapping([failureModule], mapping); + } + if (preprocessorModule) { + extractCurrentMapping([preprocessorModule], mapping); + } + if (!modules || !Array.isArray(modules)) { return mapping; } From 7fb729cc8483a2e6966a8e8995678929f4d451a0 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:18:03 +0100 Subject: [PATCH 05/76] fix(cli): instruct agent to tell user about generate-metadata and sync push instead of running them (#8318) Co-authored-by: Claude Opus 4.5 --- cli/src/guidance/skills.ts | 101 +++++++++++++----- .../auto-generated/cli/cli-commands.md | 2 +- system_prompts/auto-generated/flow.md | 4 +- system_prompts/auto-generated/prompts.ts | 6 +- .../schemas/kafka_trigger.schema.yaml | 7 ++ .../skills/cli-commands/SKILL.md | 2 +- .../auto-generated/skills/raw-app/SKILL.md | 6 +- .../auto-generated/skills/resources/SKILL.md | 2 +- .../auto-generated/skills/schedules/SKILL.md | 2 + .../auto-generated/skills/triggers/SKILL.md | 2 + .../auto-generated/skills/write-flow/SKILL.md | 4 +- .../skills/write-script-bash/SKILL.md | 4 +- .../skills/write-script-bigquery/SKILL.md | 4 +- .../skills/write-script-bun/SKILL.md | 4 +- .../skills/write-script-bunnative/SKILL.md | 4 +- .../skills/write-script-csharp/SKILL.md | 4 +- .../skills/write-script-deno/SKILL.md | 4 +- .../skills/write-script-duckdb/SKILL.md | 4 +- .../skills/write-script-go/SKILL.md | 4 +- .../skills/write-script-graphql/SKILL.md | 4 +- .../skills/write-script-java/SKILL.md | 4 +- .../skills/write-script-mssql/SKILL.md | 4 +- .../skills/write-script-mysql/SKILL.md | 4 +- .../skills/write-script-nativets/SKILL.md | 4 +- .../skills/write-script-php/SKILL.md | 4 +- .../skills/write-script-postgresql/SKILL.md | 4 +- .../skills/write-script-powershell/SKILL.md | 4 +- .../skills/write-script-python3/SKILL.md | 4 +- .../skills/write-script-rust/SKILL.md | 4 +- .../skills/write-script-snowflake/SKILL.md | 4 +- system_prompts/base/flow-base.md | 4 +- system_prompts/base/raw-app.md | 6 +- system_prompts/base/resources.md | 2 +- system_prompts/base/schedules.md | 2 + system_prompts/base/triggers.md | 2 + system_prompts/generate.py | 4 +- 36 files changed, 177 insertions(+), 57 deletions(-) diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 34e288c7d7..e8abfdd924 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -43,10 +43,12 @@ description: MUST use when writing Go scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Go @@ -115,10 +117,12 @@ description: MUST use when writing Java scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Java @@ -167,10 +171,12 @@ description: MUST use when writing GraphQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # GraphQL @@ -226,10 +232,12 @@ description: MUST use when writing Rust scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Rust @@ -315,10 +323,12 @@ description: MUST use when writing Bun Native scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Bun Native) @@ -967,10 +977,12 @@ description: MUST use when writing PostgreSQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # PostgreSQL @@ -992,10 +1004,12 @@ description: MUST use when writing PHP scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # PHP @@ -1063,10 +1077,12 @@ description: MUST use when writing BigQuery queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # BigQuery @@ -1088,10 +1104,12 @@ description: MUST use when writing Bun/TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Bun) @@ -1742,10 +1760,12 @@ description: MUST use when writing C# scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # C# @@ -1797,10 +1817,12 @@ description: MUST use when writing MS SQL Server queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Microsoft SQL Server (MSSQL) @@ -1822,10 +1844,12 @@ description: MUST use when writing Deno/TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Deno) @@ -2480,10 +2504,12 @@ description: MUST use when writing MySQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # MySQL @@ -2505,10 +2531,12 @@ description: MUST use when writing PowerShell scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # PowerShell @@ -2574,10 +2602,12 @@ description: MUST use when writing Snowflake queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Snowflake @@ -2599,10 +2629,12 @@ description: MUST use when writing Python scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Python @@ -3404,10 +3436,12 @@ description: MUST use when writing DuckDB queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # DuckDB @@ -3469,10 +3503,12 @@ description: MUST use when writing Bash scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Bash @@ -3532,10 +3568,12 @@ description: MUST use when writing Native TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Native) @@ -4155,10 +4193,12 @@ description: MUST use when creating flows. Create a folder ending with \`.flow\` and add a YAML file with the flow definition. For rawscript modules, use \`!inline path/to/script.ts\` for the content key. -After writing: +After writing, tell the user they can run: - \`wmill flow generate-locks --yes\` - Generate lock files for the specific flow you modified (e.g. \`wmill flow generate-locks f/my_folder/my_flow.flow --yes\`) - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. @@ -4356,7 +4396,7 @@ export async function main(user_id: string) { } \`\`\` -After creating, generate lock files: +After creating, tell the user they can generate lock files by running: \`\`\`bash wmill app generate-locks \`\`\` @@ -4509,6 +4549,8 @@ data: ## CLI Commands +Tell the user they can run these commands (do NOT run them yourself): + | Command | Description | |---------|-------------| | \`wmill app new\` | Create a new raw app interactively | @@ -4525,7 +4567,7 @@ data: 3. **Keep runnables focused** - one function per file 4. **Use descriptive IDs** - \`get_user.ts\` not \`a.ts\` 5. **Always whitelist tables** - add to \`data.tables\` before querying -6. **Generate locks** - run \`wmill app generate-locks\` after adding/modifying backend runnables +6. **Generate locks** - tell the user to run \`wmill app generate-locks\` after adding/modifying backend runnables `, "triggers": `--- name: triggers @@ -4547,6 +4589,8 @@ Examples: ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + \`\`\`bash # Push trigger configuration wmill sync push @@ -4596,6 +4640,8 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + \`\`\`bash # Push schedules to Windmill wmill sync push @@ -4851,7 +4897,7 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources +# Push resources (tell the user to run this, do NOT run it yourself) wmill sync push \`\`\` `, @@ -4864,7 +4910,7 @@ description: MUST use when using the CLI. The Windmill CLI (\`wmill\`) provides commands for managing scripts, flows, apps, and other resources. -Current version: 1.651.1 +Current version: 1.653.0 ## Global Options @@ -5613,6 +5659,13 @@ properties: key: type: string value: {} + auto_offset_reset: + type: string + enum: + - latest + - earliest + description: Initial offset behavior when consumer group has no committed offset. + 'latest' starts from new messages only, 'earliest' starts from the beginning. error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index ce94c1fe28..9d80c0d52f 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -2,7 +2,7 @@ The Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources. -Current version: 1.651.1 +Current version: 1.653.0 ## Global Options diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index 6adbe267ef..8b00ad0a0c 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -4,10 +4,12 @@ Create a folder ending with `.flow` and add a YAML file with the flow definition. For rawscript modules, use `!inline path/to/script.ts` for the content key. -After writing: +After writing, tell the user they can run: - `wmill flow generate-locks --yes` - Generate lock files for the specific flow you modified (e.g. `wmill flow generate-locks f/my_folder/my_flow.flow --yes`) - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 6192d42931..e68374fa7f 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -35,10 +35,12 @@ export const FLOW_BASE = `# Windmill Flow Building Guide Create a folder ending with \`.flow\` and add a YAML file with the flow definition. For rawscript modules, use \`!inline path/to/script.ts\` for the content key. -After writing: +After writing, tell the user they can run: - \`wmill flow generate-locks --yes\` - Generate lock files for the specific flow you modified (e.g. \`wmill flow generate-locks f/my_folder/my_flow.flow --yes\`) - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. @@ -1356,7 +1358,7 @@ export const CLI_COMMANDS = `# Windmill CLI Commands The Windmill CLI (\`wmill\`) provides commands for managing scripts, flows, apps, and other resources. -Current version: 1.651.1 +Current version: 1.653.0 ## Global Options diff --git a/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml b/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml index 1a0c98ef41..74cfa8e615 100644 --- a/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml @@ -25,6 +25,13 @@ properties: key: type: string value: {} + auto_offset_reset: + type: string + enum: + - latest + - earliest + description: Initial offset behavior when consumer group has no committed offset. + 'latest' starts from new messages only, 'earliest' starts from the beginning. error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 66d8d51b4a..562d0b8536 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -7,7 +7,7 @@ description: MUST use when using the CLI. The Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources. -Current version: 1.651.1 +Current version: 1.653.0 ## Global Options diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 8fb05e43c9..533e5f7c3e 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -84,7 +84,7 @@ export async function main(user_id: string) { } ``` -After creating, generate lock files: +After creating, tell the user they can generate lock files by running: ```bash wmill app generate-locks ``` @@ -237,6 +237,8 @@ data: ## CLI Commands +Tell the user they can run these commands (do NOT run them yourself): + | Command | Description | |---------|-------------| | `wmill app new` | Create a new raw app interactively | @@ -253,4 +255,4 @@ data: 3. **Keep runnables focused** - one function per file 4. **Use descriptive IDs** - `get_user.ts` not `a.ts` 5. **Always whitelist tables** - add to `data.tables` before querying -6. **Generate locks** - run `wmill app generate-locks` after adding/modifying backend runnables +6. **Generate locks** - tell the user to run `wmill app generate-locks` after adding/modifying backend runnables diff --git a/system_prompts/auto-generated/skills/resources/SKILL.md b/system_prompts/auto-generated/skills/resources/SKILL.md index 649cb39cbb..3f78cc1b0b 100644 --- a/system_prompts/auto-generated/skills/resources/SKILL.md +++ b/system_prompts/auto-generated/skills/resources/SKILL.md @@ -242,6 +242,6 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources +# Push resources (tell the user to run this, do NOT run it yourself) wmill sync push ``` diff --git a/system_prompts/auto-generated/skills/schedules/SKILL.md b/system_prompts/auto-generated/skills/schedules/SKILL.md index 1cd64cb002..24dab471e5 100644 --- a/system_prompts/auto-generated/skills/schedules/SKILL.md +++ b/system_prompts/auto-generated/skills/schedules/SKILL.md @@ -39,6 +39,8 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + ```bash # Push schedules to Windmill wmill sync push diff --git a/system_prompts/auto-generated/skills/triggers/SKILL.md b/system_prompts/auto-generated/skills/triggers/SKILL.md index 5f3a532051..183f447175 100644 --- a/system_prompts/auto-generated/skills/triggers/SKILL.md +++ b/system_prompts/auto-generated/skills/triggers/SKILL.md @@ -18,6 +18,8 @@ Examples: ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + ```bash # Push trigger configuration wmill sync push diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index e4a8bc976c..f844b813bd 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -9,10 +9,12 @@ description: MUST use when creating flows. Create a folder ending with `.flow` and add a YAML file with the flow definition. For rawscript modules, use `!inline path/to/script.ts` for the content key. -After writing: +After writing, tell the user they can run: - `wmill flow generate-locks --yes` - Generate lock files for the specific flow you modified (e.g. `wmill flow generate-locks f/my_folder/my_flow.flow --yes`) - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. diff --git a/system_prompts/auto-generated/skills/write-script-bash/SKILL.md b/system_prompts/auto-generated/skills/write-script-bash/SKILL.md index a914902deb..b776538001 100644 --- a/system_prompts/auto-generated/skills/write-script-bash/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bash/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Bash scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Bash diff --git a/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md b/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md index 8561d3170e..cd3c9e1610 100644 --- a/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing BigQuery queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # BigQuery diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 3758a172e2..6957a139f9 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Bun/TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # TypeScript (Bun) diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index 0ae5b57474..731462addd 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Bun Native scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # TypeScript (Bun Native) diff --git a/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md b/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md index e0d268d55e..ca807520e0 100644 --- a/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing C# scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # C# diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index a23c8ceccd..a925e9835a 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Deno/TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # TypeScript (Deno) diff --git a/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md b/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md index 04f6a3fdec..1df6392db9 100644 --- a/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing DuckDB queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # DuckDB diff --git a/system_prompts/auto-generated/skills/write-script-go/SKILL.md b/system_prompts/auto-generated/skills/write-script-go/SKILL.md index ff6b1c490c..894a1dd791 100644 --- a/system_prompts/auto-generated/skills/write-script-go/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-go/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Go scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Go diff --git a/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md b/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md index 452a1d4734..0749cc47ef 100644 --- a/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing GraphQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # GraphQL diff --git a/system_prompts/auto-generated/skills/write-script-java/SKILL.md b/system_prompts/auto-generated/skills/write-script-java/SKILL.md index facc50899e..811fa875ef 100644 --- a/system_prompts/auto-generated/skills/write-script-java/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-java/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Java scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Java diff --git a/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md b/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md index 58ea4982a2..f6bc5e008a 100644 --- a/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing MS SQL Server queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Microsoft SQL Server (MSSQL) diff --git a/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md b/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md index 8028fa6f1c..28ba025931 100644 --- a/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing MySQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # MySQL diff --git a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md index 18eebcbc74..24d2c14440 100644 --- a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Native TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # TypeScript (Native) diff --git a/system_prompts/auto-generated/skills/write-script-php/SKILL.md b/system_prompts/auto-generated/skills/write-script-php/SKILL.md index c2d02ff3bf..a8d6b2b0ab 100644 --- a/system_prompts/auto-generated/skills/write-script-php/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-php/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing PHP scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # PHP diff --git a/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md b/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md index df1fc6b5ca..ccb4654fff 100644 --- a/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing PostgreSQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # PostgreSQL diff --git a/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md b/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md index e54f3e647b..fefc379b12 100644 --- a/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing PowerShell scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # PowerShell diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index 15e459c241..5d6007df7a 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Python scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Python diff --git a/system_prompts/auto-generated/skills/write-script-rust/SKILL.md b/system_prompts/auto-generated/skills/write-script-rust/SKILL.md index 3dad884ebd..044cb9059a 100644 --- a/system_prompts/auto-generated/skills/write-script-rust/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-rust/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Rust scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Rust diff --git a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md index 54667c0432..24b8d06d4f 100644 --- a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Snowflake queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Snowflake diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index 513617693e..55d4c06b58 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -4,10 +4,12 @@ Create a folder ending with `.flow` and add a YAML file with the flow definition. For rawscript modules, use `!inline path/to/script.ts` for the content key. -After writing: +After writing, tell the user they can run: - `wmill flow generate-locks --yes` - Generate lock files for the specific flow you modified (e.g. `wmill flow generate-locks f/my_folder/my_flow.flow --yes`) - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 0dbcac9462..5d68232eda 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -79,7 +79,7 @@ export async function main(user_id: string) { } ``` -After creating, generate lock files: +After creating, tell the user they can generate lock files by running: ```bash wmill app generate-locks ``` @@ -232,6 +232,8 @@ data: ## CLI Commands +Tell the user they can run these commands (do NOT run them yourself): + | Command | Description | |---------|-------------| | `wmill app new` | Create a new raw app interactively | @@ -248,4 +250,4 @@ data: 3. **Keep runnables focused** - one function per file 4. **Use descriptive IDs** - `get_user.ts` not `a.ts` 5. **Always whitelist tables** - add to `data.tables` before querying -6. **Generate locks** - run `wmill app generate-locks` after adding/modifying backend runnables +6. **Generate locks** - tell the user to run `wmill app generate-locks` after adding/modifying backend runnables diff --git a/system_prompts/base/resources.md b/system_prompts/base/resources.md index 290d6f617b..0f51f6d322 100644 --- a/system_prompts/base/resources.md +++ b/system_prompts/base/resources.md @@ -237,6 +237,6 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources +# Push resources (tell the user to run this, do NOT run it yourself) wmill sync push ``` diff --git a/system_prompts/base/schedules.md b/system_prompts/base/schedules.md index bf14d24cbd..8e50fb87a6 100644 --- a/system_prompts/base/schedules.md +++ b/system_prompts/base/schedules.md @@ -34,6 +34,8 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + ```bash # Push schedules to Windmill wmill sync push diff --git a/system_prompts/base/triggers.md b/system_prompts/base/triggers.md index 4998b85342..5205eb2d4f 100644 --- a/system_prompts/base/triggers.md +++ b/system_prompts/base/triggers.md @@ -13,6 +13,8 @@ Examples: ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + ```bash # Push trigger configuration wmill sync push diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 0af7fe0eb6..122b85e61a 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -763,10 +763,12 @@ def generate_skills( # CLI intro for script skills script_cli_intro = """## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types.""" skills_generated = [] From f3e9a29c13ac9420febccd0c1c4e7d5474f03a89 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 12 Mar 2026 12:28:17 +0000 Subject: [PATCH 06/76] add customUi props for History and Save to workspace editor bar buttons (#8336) Co-authored-by: Claude Opus 4.6 --- frontend/src/lib/components/EditorBar.svelte | 4 ++-- frontend/src/lib/components/custom_ui.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index cdaec4c44c..4ddbb70b19 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -1096,7 +1096,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
{@render right?.()} - {#if scriptPath && !noHistory} + {#if scriptPath && !noHistory && customUi?.history != false}
{#if !emptyString(repo.git_repo_resource_path)} diff --git a/frontend/src/lib/hub.ts b/frontend/src/lib/hub.ts index fcaa2ab832..a038f18580 100644 --- a/frontend/src/lib/hub.ts +++ b/frontend/src/lib/hub.ts @@ -92,7 +92,6 @@ export function rawAppToHubUrl(hubBaseUrl: string, summary?: string): URL { } type HubPaths = { - gitSync: string gitSyncTest: string gitInitRepo: string slackErrorHandler: string diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 79bf362003..d62fffa097 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -1,28 +1,28 @@ { - "gitSync_0": "hub/9087/sync-script-to-git-repo-windmill", - "gitSync_1": "hub/9987/sync-script-to-git-repo-windmill", - "gitSync_2": "hub/11498/sync-script-to-git-repo-windmill", - "gitSync_3": "hub/11533/sync-script-to-git-repo-windmill", - "gitSync_4": "hub/11580/sync-script-to-git-repo-windmill", - "gitSync_5": "hub/11641/sync-script-to-git-repo-windmill", - "gitSync_6": "hub/11666/sync-script-to-git-repo-windmill", - "gitSync_7": "hub/11668/sync-script-to-git-repo-windmill", - "gitSync_8": "hub/19673/sync-script-to-git-repo-windmill", - "gitSync_9": "hub/19738/sync-script-to-git-repo-windmill", - "gitSync_10": "hub/19785/sync-script-to-git-repo-windmill", - "gitSync_11": "hub/19789/sync-script-to-git-repo-windmill", - "gitSync_12": "hub/19798/sync-script-to-git-repo-windmill", - "gitSync_13": "hub/19801/sync-script-to-git-repo-windmill", - "gitSync_14": "hub/19803/sync-script-to-git-repo-windmill", - "gitSync_15": "hub/19816/sync-script-to-git-repo-windmill", - "gitSync_16": "hub/19818/sync-script-to-git-repo-windmill", - "gitSync_17": "hub/28073/sync-script-to-git-repo-windmill", - "gitSync_18": "hub/28078/sync-script-to-git-repo-windmill", - "gitSync_19": "hub/28081/sync-script-to-git-repo-windmill", - "gitSync_20": "hub/28102/sync-script-to-git-repo-windmill", - "gitSync_21": "hub/28131/sync-script-to-git-repo-windmill", - "gitSync_22": "hub/28159/sync-script-to-git-repo-windmill", - "gitSync": "hub/28160/sync-script-to-git-repo-windmill", + "deprecated_gitSync_0": "hub/9087/sync-script-to-git-repo-windmill", + "deprecated_gitSync_1": "hub/9987/sync-script-to-git-repo-windmill", + "deprecated_gitSync_2": "hub/11498/sync-script-to-git-repo-windmill", + "deprecated_gitSync_3": "hub/11533/sync-script-to-git-repo-windmill", + "deprecated_gitSync_4": "hub/11580/sync-script-to-git-repo-windmill", + "deprecated_gitSync_5": "hub/11641/sync-script-to-git-repo-windmill", + "deprecated_gitSync_6": "hub/11666/sync-script-to-git-repo-windmill", + "deprecated_gitSync_7": "hub/11668/sync-script-to-git-repo-windmill", + "deprecated_gitSync_8": "hub/19673/sync-script-to-git-repo-windmill", + "deprecated_gitSync_9": "hub/19738/sync-script-to-git-repo-windmill", + "deprecated_gitSync_10": "hub/19785/sync-script-to-git-repo-windmill", + "deprecated_gitSync_11": "hub/19789/sync-script-to-git-repo-windmill", + "deprecated_gitSync_12": "hub/19798/sync-script-to-git-repo-windmill", + "deprecated_gitSync_13": "hub/19801/sync-script-to-git-repo-windmill", + "deprecated_gitSync_14": "hub/19803/sync-script-to-git-repo-windmill", + "deprecated_gitSync_15": "hub/19816/sync-script-to-git-repo-windmill", + "deprecated_gitSync_16": "hub/19818/sync-script-to-git-repo-windmill", + "deprecated_gitSync_17": "hub/28073/sync-script-to-git-repo-windmill", + "deprecated_gitSync_18": "hub/28078/sync-script-to-git-repo-windmill", + "deprecated_gitSync_19": "hub/28081/sync-script-to-git-repo-windmill", + "deprecated_gitSync_20": "hub/28102/sync-script-to-git-repo-windmill", + "deprecated_gitSync_21": "hub/28131/sync-script-to-git-repo-windmill", + "deprecated_gitSync_22": "hub/28159/sync-script-to-git-repo-windmill", + "deprecated_gitSync_latest": "hub/28160/sync-script-to-git-repo-windmill", "gitSyncTest": "hub/19799/git-repo-test-read-write-windmill", "gitInitRepo_0": "hub/28134/git-sync%3A-init-repository-windmill", "gitInitRepo": "hub/28158/git-sync%3A-init-repository-windmill", diff --git a/integration_tests/requirements.txt b/integration_tests/requirements.txt index 97572f1b84..35b4f13cf4 100644 --- a/integration_tests/requirements.txt +++ b/integration_tests/requirements.txt @@ -6,3 +6,4 @@ httpx==0.26.0 idna==3.6 sniffio==1.3.0 docker==7.1.0 +gitpython==3.1.43 diff --git a/integration_tests/test/git_sync_test.py b/integration_tests/test/git_sync_test.py new file mode 100644 index 0000000000..5268fefa01 --- /dev/null +++ b/integration_tests/test/git_sync_test.py @@ -0,0 +1,792 @@ +import os +import shutil +import tempfile +import time +import unittest +import uuid + +import git as gitpython + +from .wmill_integration_test_utils import WindmillClient, GiteaClient + + +# Script content template for bun/TypeScript scripts +def ts_script(body: str) -> str: + return f"export async function main() {{\n {body}\n}}\n" + + +def unique_name(prefix: str = "git-sync-test") -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +class TestGitSync(unittest.TestCase): + _client: WindmillClient + _gitea: GiteaClient + _repos_to_cleanup: list + _fork_workspaces_to_cleanup: list + + @classmethod + def setUpClass(cls) -> None: + print("Running {}".format(cls.__name__)) + cls._client = WindmillClient() + cls._gitea = GiteaClient() + cls._gitea.setup_admin() + cls._repos_to_cleanup = [] + cls._fork_workspaces_to_cleanup = [] + + @classmethod + def tearDownClass(cls) -> None: + # Disable git sync to avoid interfering with other tests + try: + cls._client.configure_git_sync({"repositories": []}) + except Exception as e: + print(f"Warning: failed to disable git sync: {e}") + + for fork_id in cls._fork_workspaces_to_cleanup: + try: + cls._client.delete_workspace(fork_id) + except Exception as e: + print(f"Warning: failed to delete fork workspace {fork_id}: {e}") + + for repo_name in cls._repos_to_cleanup: + cls._gitea.delete_repo(repo_name) + + def setUp(self): + """Wait for any pending deployment callbacks from previous tests to drain.""" + time.sleep(2) + # Wait until no new deployment callback jobs appear for 4 seconds + prev_count = self._client.count_deployment_callback_jobs() + for _ in range(3): + time.sleep(2) + cur_count = self._client.count_deployment_callback_jobs() + if cur_count == prev_count: + break + prev_count = cur_count + + def _create_test_repo(self) -> tuple: + """Create a Gitea repo and return (repo_name, docker_clone_url).""" + name = unique_name() + docker_url = self._gitea.create_repo(name) + self._repos_to_cleanup.append(name) + return name, docker_url + + def _setup_git_sync_resource(self, repo_name: str, branch: str = "main") -> str: + """Create a git_repository resource pointing to the Gitea repo. + Returns the resource path.""" + resource_path = f"u/admin/git_sync_{repo_name.replace('-', '_')}" + docker_url = self._gitea.get_docker_clone_url(repo_name) + self._client.create_resource( + path=resource_path, + resource_type="git_repository", + value={ + "url": docker_url, + "branch": branch, + "is_github_app": False, + }, + update_if_exists=True, + ) + return resource_path + + def _configure_single_repo_sync( + self, + resource_path: str, + include_type=None, + include_path=None, + use_individual_branch=False, + group_by_folder=False, + force_branch=None, + ): + """Configure git sync with a single repository (auto-managed script).""" + repo_settings = { + "git_repo_resource_path": f"$res:{resource_path}", + "use_individual_branch": use_individual_branch, + "group_by_folder": group_by_folder, + } + if force_branch: + repo_settings["force_branch"] = force_branch + if include_type or include_path: + repo_settings["settings"] = { + "include_type": include_type or [], + "include_path": include_path if include_path is not None else ["**"], + } + + self._client.configure_git_sync({ + "repositories": [repo_settings], + }) + + def _clone_repo(self, repo_name: str, branch: str = None) -> str: + """Clone the repo to a temp dir and return the path.""" + host_url = self._gitea.get_host_clone_url(repo_name) + tmp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp_dir, ignore_errors=True) + args = {} + if branch: + args["branch"] = branch + gitpython.Repo.clone_from(host_url, tmp_dir, **args) + return tmp_dir + + def _clone_repo_all_branches(self, repo_name: str) -> str: + """Clone the repo fetching all branches.""" + host_url = self._gitea.get_host_clone_url(repo_name) + tmp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp_dir, ignore_errors=True) + gitpython.Repo.clone_from(host_url, tmp_dir, no_single_branch=True) + return tmp_dir + + def _list_repo_files(self, repo_dir: str, branch: str = None) -> list: + """List all tracked files in the repo (relative paths).""" + repo = gitpython.Repo(repo_dir) + if branch: + commit = repo.refs[branch].commit + else: + commit = repo.head.commit + return [item.path for item in commit.tree.traverse()] + + def _read_file_content(self, repo_dir: str, file_path: str) -> str: + """Read a file's content from the repo working tree.""" + full_path = os.path.join(repo_dir, file_path) + with open(full_path, "r") as f: + return f.read() + + def _get_commit_count(self, repo_dir: str, branch: str = "main") -> int: + repo = gitpython.Repo(repo_dir) + return len(list(repo.iter_commits(branch))) + + def _get_last_commit_message(self, repo_dir: str, branch: str = "main") -> str: + repo = gitpython.Repo(repo_dir) + return repo.iter_commits(branch).__next__().message + + def _get_branches(self, repo_dir: str) -> list: + repo = gitpython.Repo(repo_dir) + return [ref.name for ref in repo.remote().refs] + + def _create_folder(self, folder_name: str): + """Create a folder in the workspace, ignoring errors if it already exists.""" + try: + self._client._client.post( + f"/api/w/{self._client._workspace}/folders/create", + json={"name": folder_name}, + ) + except Exception: + pass + + # ────────────────────────────────────────────────── + # Core happy-path tests + # ────────────────────────────────────────────────── + + def test_script_deploy_syncs_to_git(self): + """Deploy a script and verify it appears in the git repo with correct content.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync( + resource_path, + include_type=["script"], + ) + + initial_count = self._client.count_deployment_callback_jobs() + + script_path = f"u/admin/{unique_name('sync_test')}" + self._client.create_script( + path=script_path, + content=ts_script("return 42"), + language="bun", + ) + + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + repo_dir = self._clone_repo(repo_name) + files = self._list_repo_files(repo_dir) + + # The script should appear in the repo + matching = [f for f in files if script_path in f] + self.assertTrue( + len(matching) > 0, + f"Expected script '{script_path}' in repo files: {files}", + ) + + # Verify file content matches what we deployed + script_file = [f for f in matching if f.endswith(".ts")][0] + content = self._read_file_content(repo_dir, script_file) + self.assertIn( + "return 42", + content, + f"Expected 'return 42' in script content: {content}", + ) + + def test_multi_repo_routing(self): + """Two repos with different path filters receive the correct objects.""" + repo_name_a, _ = self._create_test_repo() + repo_name_b, _ = self._create_test_repo() + res_path_a = self._setup_git_sync_resource(repo_name_a) + res_path_b = self._setup_git_sync_resource(repo_name_b) + + folder_a = unique_name("folder_a") + folder_b = unique_name("folder_b") + + self._client.configure_git_sync({ + "repositories": [ + { + "git_repo_resource_path": f"$res:{res_path_a}", + "use_individual_branch": False, + "group_by_folder": False, + "settings": { + "include_type": ["script"], + "include_path": [f"f/{folder_a}/**"], + }, + }, + { + "git_repo_resource_path": f"$res:{res_path_b}", + "use_individual_branch": False, + "group_by_folder": False, + "settings": { + "include_type": ["script"], + "include_path": [f"f/{folder_b}/**"], + }, + }, + ], + }) + + self._create_folder(folder_a) + self._create_folder(folder_b) + + initial_count = self._client.count_deployment_callback_jobs() + + script_a = f"f/{folder_a}/script_a" + script_b = f"f/{folder_b}/script_b" + + self._client.create_script( + path=script_a, + content=ts_script("return 'a'"), + language="bun", + ) + self._client.create_script( + path=script_b, + content=ts_script("return 'b'"), + language="bun", + ) + + # Wait for at least 2 deployment callback jobs + self._client.wait_for_sync_jobs(initial_count, min_new=2) + time.sleep(3) + + # Verify repo A has script_a but not script_b + repo_dir_a = self._clone_repo(repo_name_a) + files_a = self._list_repo_files(repo_dir_a) + self.assertTrue( + any("script_a" in f for f in files_a), + f"Expected script_a in repo A files: {files_a}", + ) + self.assertFalse( + any("script_b" in f for f in files_a), + f"Did not expect script_b in repo A files: {files_a}", + ) + + # Verify repo B has script_b but not script_a + repo_dir_b = self._clone_repo(repo_name_b) + files_b = self._list_repo_files(repo_dir_b) + self.assertTrue( + any("script_b" in f for f in files_b), + f"Expected script_b in repo B files: {files_b}", + ) + self.assertFalse( + any("script_a" in f for f in files_b), + f"Did not expect script_a in repo B files: {files_b}", + ) + + def test_script_update_creates_new_commit_with_updated_content(self): + """Updating a script should produce a new commit with the new content.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync(resource_path, include_type=["script"]) + + script_path = f"u/admin/{unique_name('update_test')}" + + # Create initial script + initial_count = self._client.count_deployment_callback_jobs() + self._client.create_script( + path=script_path, + content=ts_script("return 1"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + repo_dir = self._clone_repo(repo_name) + initial_commits = self._get_commit_count(repo_dir) + + # Update the script + update_count = self._client.count_deployment_callback_jobs() + self._client.update_script( + path=script_path, + content=ts_script("return 2"), + language="bun", + ) + self._client.wait_for_sync_jobs(update_count, min_new=1) + time.sleep(3) + + # Re-clone and check commit count increased + repo_dir2 = self._clone_repo(repo_name) + new_commits = self._get_commit_count(repo_dir2) + self.assertGreater( + new_commits, + initial_commits, + f"Expected more commits after update: {new_commits} vs {initial_commits}", + ) + + # Verify file content reflects the update + files = self._list_repo_files(repo_dir2) + script_file = [f for f in files if script_path in f and f.endswith(".ts")][0] + content = self._read_file_content(repo_dir2, script_file) + self.assertIn( + "return 2", + content, + f"Expected 'return 2' in updated script content: {content}", + ) + self.assertNotIn( + "return 1", + content, + f"Did not expect 'return 1' in updated script content: {content}", + ) + + def test_deploy_multiple_object_types(self): + """Deploy a script, flow, and variable and verify all appear in the repo.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync( + resource_path, + include_type=["script", "flow", "variable"], + ) + + initial_count = self._client.count_deployment_callback_jobs() + + suffix = unique_name("multi") + script_path = f"u/admin/{suffix}_script" + flow_path = f"u/admin/{suffix}_flow" + var_path = f"u/admin/{suffix}_var" + + self._client.create_script( + path=script_path, + content=ts_script("return 'multi'"), + language="bun", + ) + self._client.create_flow( + path=flow_path, + flow_value_json="""{ + "summary": "test flow", + "value": { + "modules": [{ + "id": "a", + "value": { + "type": "rawscript", + "content": "export async function main() { return 1 }", + "language": "bun", + "input_transforms": {}, + "tag": "" + } + }] + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {}, + "required": [], + "type": "object", + "order": [] + } + }""", + ) + self._client.create_variable( + path=var_path, + value="test_value", + ) + + # Wait for 3 deployment callbacks (one per object) + self._client.wait_for_sync_jobs(initial_count, min_new=3) + time.sleep(3) + + repo_dir = self._clone_repo(repo_name) + files = self._list_repo_files(repo_dir) + files_str = "\n".join(files) + + self.assertTrue( + any(suffix + "_script" in f for f in files), + f"Expected script in repo:\n{files_str}", + ) + self.assertTrue( + any(suffix + "_flow" in f for f in files), + f"Expected flow in repo:\n{files_str}", + ) + self.assertTrue( + any(suffix + "_var" in f for f in files), + f"Expected variable in repo:\n{files_str}", + ) + + # ────────────────────────────────────────────────── + # Commit message verification + # ────────────────────────────────────────────────── + + def test_commit_message_format(self): + """Verify commit messages have the [WM] prefix.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync(resource_path, include_type=["script"]) + + initial_count = self._client.count_deployment_callback_jobs() + script_path = f"u/admin/{unique_name('commit_msg')}" + self._client.create_script( + path=script_path, + content=ts_script("return 'msg'"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + repo_dir = self._clone_repo(repo_name) + commit_msg = self._get_last_commit_message(repo_dir) + + self.assertTrue( + commit_msg.startswith("[WM]"), + f"Expected commit message to start with '[WM]', got: {commit_msg!r}", + ) + + # ────────────────────────────────────────────────── + # Rename handling + # ────────────────────────────────────────────────── + + def test_rename_removes_old_file(self): + """Renaming a script should remove the old file and create the new one.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync(resource_path, include_type=["script"]) + + old_path = f"u/admin/{unique_name('rename_old')}" + + # Create initial script + initial_count = self._client.count_deployment_callback_jobs() + self._client.create_script( + path=old_path, + content=ts_script("return 'old'"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + # Verify old script exists in repo + repo_dir = self._clone_repo(repo_name) + files = self._list_repo_files(repo_dir) + old_name = old_path.split("/")[-1] + self.assertTrue( + any(old_name in f for f in files), + f"Expected old script '{old_name}' in repo: {files}", + ) + + # Create new script at different path (simulates rename) + new_path = f"u/admin/{unique_name('rename_new')}" + rename_count = self._client.count_deployment_callback_jobs() + self._client.create_script( + path=new_path, + content=ts_script("return 'renamed'"), + language="bun", + ) + # Also delete the old script + self._client.delete_script(old_path) + # Wait for both create and delete deployment callbacks + self._client.wait_for_sync_jobs(rename_count, min_new=2) + time.sleep(3) + + # Verify new script exists + repo_dir2 = self._clone_repo(repo_name) + files2 = self._list_repo_files(repo_dir2) + new_name = new_path.split("/")[-1] + self.assertTrue( + any(new_name in f for f in files2), + f"Expected new script '{new_name}' in repo: {files2}", + ) + self.assertFalse( + any(old_name in f for f in files2), + f"Expected old script '{old_name}' to be removed: {files2}", + ) + + # ────────────────────────────────────────────────── + # Promotion mode (individual branches) + # ────────────────────────────────────────────────── + + def test_promotion_mode_creates_per_object_branches(self): + """In promotion mode (use_individual_branch=True), each deploy creates + a branch named wm_deploy/{workspace}/{path_type}/{path} with the content + on that branch, not on main.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync( + resource_path, + include_type=["script"], + use_individual_branch=True, + ) + + initial_count = self._client.count_deployment_callback_jobs() + script_path = f"u/admin/{unique_name('promo')}" + self._client.create_script( + path=script_path, + content=ts_script("return 'promotion'"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + # Clone with all branches + repo_dir = self._clone_repo_all_branches(repo_name) + branches = self._get_branches(repo_dir) + + # Should have a branch matching wm_deploy pattern + wm_branches = [b for b in branches if "wm_deploy/" in b] + self.assertTrue( + len(wm_branches) > 0, + f"Expected wm_deploy/ branch, got branches: {branches}", + ) + + # The branch name should contain 'script' (the path_type) + deploy_branch = wm_branches[0] + self.assertIn( + "script", + deploy_branch, + f"Expected 'script' in branch name: {deploy_branch}", + ) + + # The script path (with / replaced by __) should appear in the branch name + script_name = script_path.split("/")[-1] + self.assertIn( + script_name, + deploy_branch.replace("/", "__"), + f"Expected script name '{script_name}' in branch: {deploy_branch}", + ) + + # Verify main branch does NOT have the script + main_files = self._list_repo_files(repo_dir, branch="origin/main") + self.assertFalse( + any(script_name in f for f in main_files), + f"Did not expect script on main branch, but found it: {main_files}", + ) + + # Verify the deploy branch HAS the script + local_branch_name = deploy_branch.replace("origin/", "") + repo = gitpython.Repo(repo_dir) + repo.git.checkout(local_branch_name) + branch_files = self._list_repo_files(repo_dir) + self.assertTrue( + any(script_name in f for f in branch_files), + f"Expected script on deploy branch '{local_branch_name}': {branch_files}", + ) + + def test_promotion_mode_group_by_folder(self): + """With use_individual_branch=True and group_by_folder=True, the branch name + uses the folder prefix (first 2 path segments joined by __) instead of the + full path.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync( + resource_path, + include_type=["script"], + use_individual_branch=True, + group_by_folder=True, + ) + + folder_name = unique_name("grp") + self._create_folder(folder_name) + + initial_count = self._client.count_deployment_callback_jobs() + script_path = f"f/{folder_name}/{unique_name('grp_script')}" + self._client.create_script( + path=script_path, + content=ts_script("return 'grouped'"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + repo_dir = self._clone_repo_all_branches(repo_name) + branches = self._get_branches(repo_dir) + + wm_branches = [b for b in branches if "wm_deploy/" in b] + self.assertTrue( + len(wm_branches) > 0, + f"Expected wm_deploy/ branch with group_by_folder: {branches}", + ) + + # With group_by_folder, the branch should contain the folder prefix + # format: wm_deploy/{workspace}/f__{folder_name} + deploy_branch = wm_branches[0] + expected_folder_part = f"f__{folder_name}" + self.assertIn( + expected_folder_part, + deploy_branch, + f"Expected folder-grouped branch name containing '{expected_folder_part}', got: {deploy_branch}", + ) + + # ────────────────────────────────────────────────── + # force_branch with wmill.yaml + # ────────────────────────────────────────────────── + + def test_force_branch_with_wmill_yaml(self): + """force_branch passes --branch to wmill sync pull, which selects the + matching gitBranches config from wmill.yaml. With branch-specific variables + configured, the variable file should use a branch-specific path.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + + # Push a wmill.yaml to the repo that configures branch-specific variables + wmill_yaml_content = """\ +includes: + - "**" +gitBranches: + staging: + specificItems: + variables: + - "**" +""" + self._gitea.create_file(repo_name, "wmill.yaml", wmill_yaml_content) + + self._configure_single_repo_sync( + resource_path, + include_type=["variable"], + force_branch="staging", + ) + + initial_count = self._client.count_deployment_callback_jobs() + var_path = f"u/admin/{unique_name('env_var')}" + self._client.create_variable( + path=var_path, + value="staging_value", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + repo_dir = self._clone_repo(repo_name) + files = self._list_repo_files(repo_dir) + + var_name = var_path.split("/")[-1] + + # With force_branch="staging" and specificItems for variables, + # the variable file should have ".staging." in its name + staging_files = [f for f in files if var_name in f and ".staging." in f] + self.assertTrue( + len(staging_files) > 0, + f"Expected variable file with '.staging.' in name for branch-specific item, " + f"got files: {files}", + ) + + # ────────────────────────────────────────────────── + # Exclude path filtering + # ────────────────────────────────────────────────── + + def test_exclude_path_filtering(self): + """Scripts in excluded paths should not be synced to the repo.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + + folder_inc = unique_name("inc") + folder_exc = unique_name("exc") + self._create_folder(folder_inc) + self._create_folder(folder_exc) + + self._client.configure_git_sync({ + "repositories": [{ + "git_repo_resource_path": f"$res:{resource_path}", + "use_individual_branch": False, + "group_by_folder": False, + "settings": { + "include_type": ["script"], + "include_path": ["f/**"], + "exclude_path": [f"f/{folder_exc}/**"], + }, + }], + }) + + initial_count = self._client.count_deployment_callback_jobs() + + script_inc = f"f/{folder_inc}/included_script" + script_exc = f"f/{folder_exc}/excluded_script" + + self._client.create_script( + path=script_inc, + content=ts_script("return 'included'"), + language="bun", + ) + self._client.create_script( + path=script_exc, + content=ts_script("return 'excluded'"), + language="bun", + ) + + # Only 1 sync job expected (the excluded one should not trigger) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(5) + # Verify no extra sync jobs arrived for the excluded script + final_count = self._client.count_deployment_callback_jobs() + self.assertEqual( + final_count, initial_count + 1, + f"Expected exactly 1 new sync job, got {final_count - initial_count}", + ) + + repo_dir = self._clone_repo(repo_name) + files = self._list_repo_files(repo_dir) + + self.assertTrue( + any("included_script" in f for f in files), + f"Expected included_script in repo: {files}", + ) + self.assertFalse( + any("excluded_script" in f for f in files), + f"Did not expect excluded_script in repo: {files}", + ) + + # ────────────────────────────────────────────────── + # Workspace fork + # ────────────────────────────────────────────────── + + def test_workspace_fork_creates_branch(self): + """Forking a workspace with git sync configured should create a + fork branch in the git repo.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + + # Configure git sync on the parent workspace (sync mode, not promotion) + self._configure_single_repo_sync( + resource_path, + include_type=["script"], + ) + + # Deploy a script first so there's content in the repo + initial_count = self._client.count_deployment_callback_jobs() + script_path = f"u/admin/{unique_name('fork_base')}" + self._client.create_script( + path=script_path, + content=ts_script("return 'base'"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + # Create workspace fork + fork_id = f"wm-fork-{uuid.uuid4().hex[:8]}" + fork_name = f"Fork {fork_id}" + self._fork_workspaces_to_cleanup.append(fork_id) + + # Step 1: Create git branches for the fork + job_ids = self._client.create_workspace_fork_branch(fork_id, fork_name) + if job_ids: + self._client.wait_for_jobs_by_ids(job_ids, timeout=90) + time.sleep(3) + + # Step 2: Create the fork workspace + self._client.create_workspace_fork(fork_id, fork_name) + + # Verify a fork branch was created in the git repo + repo_dir = self._clone_repo_all_branches(repo_name) + branches = self._get_branches(repo_dir) + + # Fork branches are named: wm-fork/{original_branch}/{fork_id} + fork_branches = [b for b in branches if "wm-fork" in b] + self.assertTrue( + len(fork_branches) > 0, + f"Expected a wm-fork branch in the repo after forking, got: {branches}", + ) diff --git a/integration_tests/test/wmill_integration_test_utils.py b/integration_tests/test/wmill_integration_test_utils.py index 260986ef9b..93730f0d31 100644 --- a/integration_tests/test/wmill_integration_test_utils.py +++ b/integration_tests/test/wmill_integration_test_utils.py @@ -3,6 +3,7 @@ import time import httpx import json import os +import uuid @@ -13,9 +14,11 @@ class WindmillClient: _client: httpx.Client - def __init__(self): - self._workspace = "integration-tests" - self._url = "http://localhost:8000" + def __init__(self, workspace: str = "integration-tests", url: str = None): + if url is None: + url = os.environ.get("WINDMILL_BASE_URL", "http://localhost:8000") + self._workspace = workspace + self._url = url self._token = self._login() self._client = self._init_client() @@ -155,6 +158,55 @@ class WindmillClient: time.sleep(1) raise Exception(f"Script deployment failed for {path}") + def update_script(self, path: str, content: str, language: str, tag: str = None): + """Update an existing script by creating a new version with parent_hash.""" + print(f"Updating script {path}") + + # Get current script hash + response = self._client.get( + f"/api/w/{self._workspace}/scripts/get/p/{path}" + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + current_hash = response.json()["hash"] + + payload = { + "path": path, + "content": content, + "description": "", + "summary": "", + "language": language, + "parent_hash": current_hash, + } + + if tag is not None: + payload["tag"] = tag + + response = self._client.post( + f"/api/w/{self._workspace}/scripts/create", + json=payload, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + script_hash = response.content.decode() + print(f"Script hash for path {path} is {script_hash}") + time_now = datetime.datetime.now(datetime.timezone.utc) + while datetime.datetime.now( + datetime.timezone.utc + ) - time_now < datetime.timedelta(seconds=60): + response = self._client.get( + f"/api/w/{self._workspace}/scripts/deployment_status/h/{script_hash}" + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + elif response.json()["lock"] != None: + return + elif response.json()["lock_error_logs"] != None: + raise Exception(response.json()["lock_error_logs"]) + print(f"Waiting for script {path} with hash {script_hash} to be deployed") + time.sleep(1) + raise Exception(f"Script deployment failed for {path}") + def delete_script(self, path: str): print(f"Deleting script {path}") response = self._client.post( @@ -385,6 +437,120 @@ class WindmillClient: print(f"Exception when retrieving workers list: {e}") return [] + def create_resource(self, path: str, resource_type: str, value: dict, update_if_exists: bool = False): + print(f"Creating resource {path} of type {resource_type}") + params = {} + if update_if_exists: + params["update_if_exists"] = "true" + response = self._client.post( + f"/api/w/{self._workspace}/resources/create", + json={ + "path": path, + "value": value, + "resource_type": resource_type, + "description": "", + }, + params=params, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + return response.content.decode() + + def configure_git_sync(self, git_sync_settings: dict): + print(f"Configuring git sync with {len(git_sync_settings.get('repositories', []))} repositories") + response = self._client.post( + f"/api/w/{self._workspace}/workspaces/edit_git_sync_config", + json={"git_sync_settings": git_sync_settings}, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + return response.content.decode() + + def get_completed_jobs(self, job_kinds: str = None, success: bool = None): + params = {"per_page": 1000} + if job_kinds: + params["job_kinds"] = job_kinds + if success is not None: + params["success"] = str(success).lower() + response = self._client.get( + f"/api/w/{self._workspace}/jobs/completed/list", + params=params, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + return response.json() + + def wait_for_sync_jobs(self, initial_count: int, min_new: int = 1, timeout: int = 90) -> list: + """Poll completed DeploymentCallback jobs until count increases by min_new.""" + start = time.time() + current_count = initial_count + while time.time() - start < timeout: + jobs = self.get_completed_jobs(job_kinds="deploymentcallback") + current_count = len(jobs) + if current_count >= initial_count + min_new: + return jobs + time.sleep(2) + raise TimeoutError( + f"Timed out waiting for sync jobs: expected {initial_count + min_new}, " + f"got {current_count} after {timeout}s" + ) + + def count_deployment_callback_jobs(self) -> int: + jobs = self.get_completed_jobs(job_kinds="deploymentcallback") + return len(jobs) + + def create_workspace_fork_branch(self, fork_id: str, fork_name: str) -> list: + """Create git branches for a workspace fork. Returns list of job UUIDs to wait on.""" + print(f"Creating fork branch for {fork_id} from {self._workspace}") + response = self._client.post( + f"/api/w/{self._workspace}/workspaces/create_workspace_fork_branch", + json={"id": fork_id, "name": fork_name}, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + return response.json() + + def create_workspace_fork(self, fork_id: str, fork_name: str) -> str: + """Create a forked workspace (call after fork branch jobs complete).""" + print(f"Creating fork workspace {fork_id} from {self._workspace}") + response = self._client.post( + f"/api/w/{self._workspace}/workspaces/create_fork", + json={"id": fork_id, "name": fork_name}, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + return response.content.decode() + + def wait_for_jobs_by_ids(self, job_ids: list, timeout: int = 90): + """Wait for specific jobs (by UUID) to complete.""" + start = time.time() + while time.time() - start < timeout: + all_done = True + for job_id in job_ids: + response = self._client.get( + f"/api/w/{self._workspace}/jobs_u/get/{job_id}", + ) + if response.status_code // 100 != 2: + all_done = False + break + job = response.json() + if job.get("type") != "CompletedJob": + all_done = False + break + if all_done: + return + time.sleep(2) + raise TimeoutError(f"Timed out waiting for jobs {job_ids} after {timeout}s") + + def delete_workspace(self, workspace_id: str): + """Delete a workspace.""" + print(f"Deleting workspace {workspace_id}") + response = self._client.post( + f"/api/w/{workspace_id}/workspaces/delete", + ) + if response.status_code // 100 != 2 and response.status_code != 404: + print(f"Warning: failed to delete workspace {workspace_id}: {response.content.decode()}") + def create_agent_token(self, worker_group="agent", tags=None, exp=None): """ Create an agent JWT token using superadmin privilege. @@ -418,3 +584,163 @@ class WindmillClient: token = response.content.decode().strip('"') print(f"Created agent token: {token}") return token + + +GITEA_HOST_URL = os.environ.get("GITEA_HOST_URL", "http://localhost:3000") +GITEA_DOCKER_URL = os.environ.get("GITEA_DOCKER_URL", "http://gitea:3000") +GITEA_ADMIN_USER = "windmill" +GITEA_ADMIN_PASSWORD = "password123!" +GITEA_ADMIN_EMAIL = "windmill@windmill.dev" + + +class GiteaClient: + _host_url: str + _docker_url: str + _token: str + + def __init__(self): + self._host_url = GITEA_HOST_URL + self._docker_url = GITEA_DOCKER_URL + self._token = None + + def setup_admin(self): + """Create the admin user in Gitea (idempotent) and get an API token.""" + with httpx.Client(base_url=self._host_url, timeout=30.0) as client: + # Create admin user (ignore 422 if exists) + resp = client.post( + "/api/v1/admin/users", + json={ + "username": GITEA_ADMIN_USER, + "password": GITEA_ADMIN_PASSWORD, + "email": GITEA_ADMIN_EMAIL, + "must_change_password": False, + "visibility": "public", + }, + headers={"Content-Type": "application/json"}, + auth=(GITEA_ADMIN_USER, GITEA_ADMIN_PASSWORD), + ) + if resp.status_code == 201: + print(f"Created Gitea admin user '{GITEA_ADMIN_USER}'") + elif resp.status_code == 422: + print(f"Gitea admin user '{GITEA_ADMIN_USER}' already exists") + elif resp.status_code == 401: + # Admin user doesn't exist yet; use the Gitea setup API + resp2 = client.post( + "/user/sign_up", + data={ + "user_name": GITEA_ADMIN_USER, + "password": GITEA_ADMIN_PASSWORD, + "retype": GITEA_ADMIN_PASSWORD, + "email": GITEA_ADMIN_EMAIL, + }, + ) + if resp2.status_code // 100 != 2 and resp2.status_code != 303: + # Try the API endpoint for creating the first user + resp3 = client.post( + "/api/v1/admin/users", + json={ + "username": GITEA_ADMIN_USER, + "password": GITEA_ADMIN_PASSWORD, + "email": GITEA_ADMIN_EMAIL, + "must_change_password": False, + }, + ) + if resp3.status_code // 100 != 2: + raise Exception(f"Failed to create Gitea user: {resp3.status_code} {resp3.text}") + print(f"Created Gitea admin user '{GITEA_ADMIN_USER}' via signup") + else: + raise Exception(f"Failed to create Gitea user: {resp.status_code} {resp.text}") + + # Create API token + token_name = f"integration-test-{uuid.uuid4().hex[:8]}" + resp = client.post( + f"/api/v1/users/{GITEA_ADMIN_USER}/tokens", + json={"name": token_name, "scopes": ["all"]}, + auth=(GITEA_ADMIN_USER, GITEA_ADMIN_PASSWORD), + ) + if resp.status_code // 100 != 2: + raise Exception(f"Failed to create Gitea token: {resp.status_code} {resp.text}") + self._token = resp.json()["sha1"] + print(f"Created Gitea API token: {token_name}") + + def _headers(self): + return { + "Authorization": f"token {self._token}", + "Content-Type": "application/json", + } + + def create_repo(self, name: str) -> str: + """Create a repo and return the docker-internal clone URL with credentials.""" + with httpx.Client(base_url=self._host_url, timeout=30.0) as client: + resp = client.post( + "/api/v1/user/repos", + json={ + "name": name, + "auto_init": True, + "default_branch": "main", + "private": False, + }, + headers=self._headers(), + ) + if resp.status_code // 100 != 2: + raise Exception(f"Failed to create repo {name}: {resp.status_code} {resp.text}") + print(f"Created Gitea repo: {name}") + return f"{self._docker_url}/{GITEA_ADMIN_USER}/{name}.git" + + def get_host_clone_url(self, name: str) -> str: + """Return host-accessible clone URL with credentials.""" + from urllib.parse import urlparse + parsed = urlparse(self._host_url) + return f"http://{GITEA_ADMIN_USER}:{GITEA_ADMIN_PASSWORD}@{parsed.netloc}/{GITEA_ADMIN_USER}/{name}.git" + + def get_docker_clone_url(self, name: str) -> str: + """Return clone URL accessible from the Windmill backend (docker or local).""" + from urllib.parse import urlparse + parsed = urlparse(self._docker_url) + return f"http://{GITEA_ADMIN_USER}:{GITEA_ADMIN_PASSWORD}@{parsed.netloc}/{GITEA_ADMIN_USER}/{name}.git" + + def create_file(self, repo_name: str, file_path: str, content: str, branch: str = "main"): + """Create or update a file in the repo via Gitea API.""" + import base64 + encoded = base64.b64encode(content.encode()).decode() + with httpx.Client(base_url=self._host_url, timeout=30.0) as client: + # Check if file exists (to get SHA for update) + resp = client.get( + f"/api/v1/repos/{GITEA_ADMIN_USER}/{repo_name}/contents/{file_path}", + params={"ref": branch}, + headers=self._headers(), + ) + body = { + "content": encoded, + "message": f"Add {file_path}", + "branch": branch, + } + if resp.status_code == 200: + # File exists — update with PUT + body["sha"] = resp.json()["sha"] + resp = client.put( + f"/api/v1/repos/{GITEA_ADMIN_USER}/{repo_name}/contents/{file_path}", + json=body, + headers=self._headers(), + ) + else: + # File doesn't exist — create with POST + resp = client.post( + f"/api/v1/repos/{GITEA_ADMIN_USER}/{repo_name}/contents/{file_path}", + json=body, + headers=self._headers(), + ) + if resp.status_code // 100 != 2: + raise Exception(f"Failed to create file {file_path}: {resp.status_code} {resp.text}") + print(f"Created file {file_path} in {repo_name}") + + def delete_repo(self, name: str): + with httpx.Client(base_url=self._host_url, timeout=30.0) as client: + resp = client.delete( + f"/api/v1/repos/{GITEA_ADMIN_USER}/{name}", + headers=self._headers(), + ) + if resp.status_code // 100 == 2 or resp.status_code == 404: + print(f"Deleted Gitea repo: {name}") + else: + print(f"Warning: failed to delete repo {name}: {resp.status_code}") From ec20d76216492086842c4f5e4e3b36727a5631e9 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 12 Mar 2026 15:00:30 +0100 Subject: [PATCH 08/76] feat: add auto_commit option to Kafka triggers with advanced UI badges (#8317) * feat: add auto_commit option to Kafka triggers with manual commit API Add ability to disable auto-commit on Kafka triggers so users can manually commit offsets after processing messages. This prevents message loss when processing fails. Changes: - Add `auto_commit` column to kafka_trigger table (default true) - Add POST /kafka_triggers/commit_offsets/{path} endpoint using BaseConsumer with manual assign() to avoid rebalance - Enrich trigger_info payload with partition and offset fields - Conditionally commit based on auto_commit setting - Add auto-commit toggle to frontend Kafka trigger config - Add commitKafkaOffsets helpers to Python and TypeScript SDKs - Add integration tests for auto_commit DB defaults Co-Authored-By: Claude Opus 4.6 * feat: use DB-based pending commits for kafka manual offset commit Co-Authored-By: Claude Opus 4.5 * feat: pass trigger_path to all v2 preprocessors, secure commit_offsets endpoint, fix commit semantics - Add trigger_path to v2 preprocessor event for all trigger types (kafka, nats, sqs, mqtt, gcp, postgres, websocket, http, email) - Secure commit_offsets endpoint: infer trigger from job token (OptJobAuthed) instead of requiring trigger path parameter - Fix auto_commit: only commit offset after successful job push - Fix pending commits: commit offset+1 (Kafka semantics) and use CommitMode::Sync - Update TS/Python clients and frontend preprocessor templates Co-Authored-By: Claude Opus 4.6 * feat: add advanced section badges and reorganize kafka trigger settings Co-Authored-By: Claude Opus 4.6 * fix: remove dead wm_trigger assertions from kafka e2e test Co-Authored-By: Claude Opus 4.6 * sqlx * refactor: remove unused advancedCollapsed state from all trigger editors Co-Authored-By: Claude Opus 4.6 * update ref * chore: update ee-repo-ref to ed2c9d360e6fab866b9744cc79f50038d1fc7152 This commit updates the EE repository reference after PR #452 was merged in windmill-ee-private. Previous ee-repo-ref: 5b31116a1d5a042c6a780732901cfd89584d1773 New ee-repo-ref: ed2c9d360e6fab866b9744cc79f50038d1fc7152 Automated by sync-ee-ref workflow. * fix: use path-based auth for kafka commit_offsets endpoint Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to fcd3ea52b0cc94fbe1159baf662a38da947456de This commit updates the EE repository reference after PR #457 was merged in windmill-ee-private. Previous ee-repo-ref: b3a5c33c92cb1b2caf7a65986d71da291ff72a35 New ee-repo-ref: fcd3ea52b0cc94fbe1159baf662a38da947456de Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...7fa3ab402e33edfca46daa95b52e525424586.json | 41 +++++++ ...cb24cc635836276ee6faa2438f494bfbce04.json} | 5 +- ...68554a6a4291426c09dd5b04fd832f9d31208.json | 23 ++++ ...e81abb3ec627578f2d14f0ce33896dc6ab4cf.json | 23 ++++ ...623b991c938e40d0db25290b03dc0577c2740.json | 23 ++++ ...c7e60d23e9827f23c0de5b546ed56697d54a.json} | 22 +++- ...55c13a18dfd26e237a8218d3006e2898b5aa3.json | 23 ++++ ...920837354d91a0ae16b1236c1267da71765f.json} | 5 +- ...15565490a271050dcc4b5aac2b730ae3a55b9.json | 14 +++ ...927c07832badb2c9145d5996b829163bdf7d9.json | 23 ++++ ...c047cf3b7ce22ce868db1b1a63e6c10d0d12b.json | 15 +++ ...fcbb5b647737e0366529a2f418218b1a74679.json | 18 +++ backend/Cargo.lock | 6 +- backend/ee-repo-ref.txt | 2 +- .../20260312000000_kafka_auto_commit.down.sql | 2 + .../20260312000000_kafka_auto_commit.up.sql | 14 +++ backend/summarized_schema.txt | 4 +- .../tests/trigger_e2e.rs | 18 +-- .../tests/triggers.rs | 70 ++++++++--- backend/windmill-api/openapi.yaml | 45 +++++++ backend/windmill-api/src/capture.rs | 1 + .../windmill-api/src/triggers/http/handler.rs | 1 + .../src/triggers/http/http_trigger_args.rs | 13 +- .../src/listener.rs | 3 +- backend/windmill-trigger/src/listener.rs | 12 +- .../triggers/TriggerAdvancedBadges.svelte | 28 +++++ .../email/EmailTriggerEditorInner.svelte | 7 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 9 +- .../triggers/http/RouteEditorInner.svelte | 9 ++ .../kafka/KafkaTriggerEditorInner.svelte | 112 +++++++++++++----- .../kafka/KafkaTriggersConfigSection.svelte | 23 ---- .../lib/components/triggers/kafka/utils.ts | 1 + .../mqtt/MqttTriggerEditorInner.svelte | 10 +- .../nats/NatsTriggerEditorInner.svelte | 7 +- .../PostgresTriggerEditorInner.svelte | 7 +- .../schedules/ScheduleEditorInner.svelte | 10 ++ .../triggers/sqs/SqsTriggerEditorInner.svelte | 7 +- .../WebsocketTriggerEditorInner.svelte | 12 +- frontend/src/lib/script_helpers.ts | 64 ++++++---- python-client/wmill/wmill/client.py | 25 ++++ typescript-client/build.sh | 4 +- typescript-client/client.d.ts | 13 ++ typescript-client/client.ts | 22 ++++ 43 files changed, 677 insertions(+), 119 deletions(-) create mode 100644 backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json rename backend/.sqlx/{query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json => query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json} (54%) create mode 100644 backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json create mode 100644 backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json create mode 100644 backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json rename backend/.sqlx/{query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json => query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json} (61%) create mode 100644 backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json rename backend/.sqlx/{query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json => query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json} (63%) create mode 100644 backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json create mode 100644 backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json create mode 100644 backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json create mode 100644 backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json create mode 100644 backend/migrations/20260312000000_kafka_auto_commit.down.sql create mode 100644 backend/migrations/20260312000000_kafka_auto_commit.up.sql create mode 100644 frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte diff --git a/backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json b/backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json new file mode 100644 index 0000000000..5dcf8e0792 --- /dev/null +++ b/backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, topic, partition, \"offset\" FROM kafka_pending_commits\n WHERE workspace_id = $1 AND kafka_trigger_path = $2\n ORDER BY id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "topic", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "partition", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "offset", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586" +} diff --git a/backend/.sqlx/query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json b/backend/.sqlx/query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json similarity index 54% rename from backend/.sqlx/query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json rename to backend/.sqlx/query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json index c452c33018..812c323e74 100644 --- a/backend/.sqlx/query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json +++ b/backend/.sqlx/query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE kafka_trigger\n SET\n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n filters = $4,\n auto_offset_reset = $5,\n script_path = $6,\n path = $7,\n is_flow = $8,\n edited_by = $9,\n email = $10,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $13,\n error_handler_args = $14,\n retry = $15\n WHERE\n workspace_id = $11 AND path = $12\n ", + "query": "\n UPDATE kafka_trigger\n SET\n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n filters = $4,\n auto_offset_reset = $5,\n auto_commit = $6,\n script_path = $7,\n path = $8,\n is_flow = $9,\n edited_by = $10,\n email = $11,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $14,\n error_handler_args = $15,\n retry = $16\n WHERE\n workspace_id = $12 AND path = $13\n ", "describe": { "columns": [], "parameters": { @@ -10,6 +10,7 @@ "VarcharArray", "JsonbArray", "Varchar", + "Bool", "Varchar", "Varchar", "Bool", @@ -24,5 +25,5 @@ }, "nullable": [] }, - "hash": "12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46" + "hash": "072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04" } diff --git a/backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json b/backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json new file mode 100644 index 0000000000..babf3ffbcc --- /dev/null +++ b/backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT reset_offset FROM kafka_trigger WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "reset_offset", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208" +} diff --git a/backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json b/backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json new file mode 100644 index 0000000000..b5873760fc --- /dev/null +++ b/backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id, script_path,\n is_flow, workspace_id, edited_by, email, auto_commit\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "VarcharArray", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf" +} diff --git a/backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json b/backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json new file mode 100644 index 0000000000..6dd5c799e7 --- /dev/null +++ b/backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT auto_commit FROM kafka_trigger WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "auto_commit", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740" +} diff --git a/backend/.sqlx/query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json b/backend/.sqlx/query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json similarity index 61% rename from backend/.sqlx/query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json rename to backend/.sqlx/query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json index 23652a2571..9b76b7f048 100644 --- a/backend/.sqlx/query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json +++ b/backend/.sqlx/query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT kafka_resource_path, topics, group_id, mode AS \"mode: String\"\n FROM kafka_trigger\n WHERE workspace_id = $1 AND path = $2\n ", + "query": "\n SELECT kafka_resource_path, topics, group_id, mode AS \"mode: String\",\n auto_offset_reset, auto_commit, reset_offset\n FROM kafka_trigger\n WHERE workspace_id = $1 AND path = $2\n ", "describe": { "columns": [ { @@ -33,6 +33,21 @@ } } } + }, + { + "ordinal": 4, + "name": "auto_offset_reset", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "auto_commit", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "reset_offset", + "type_info": "Bool" } ], "parameters": { @@ -42,11 +57,14 @@ ] }, "nullable": [ + false, + false, + false, false, false, false, false ] }, - "hash": "7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df" + "hash": "4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a" } diff --git a/backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json b/backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json new file mode 100644 index 0000000000..ec667519e4 --- /dev/null +++ b/backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT auto_commit\n FROM kafka_trigger\n WHERE workspace_id = $1 AND path = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "auto_commit", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3" +} diff --git a/backend/.sqlx/query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json b/backend/.sqlx/query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json similarity index 63% rename from backend/.sqlx/query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json rename to backend/.sqlx/query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json index d90a467380..936a4650f6 100644 --- a/backend/.sqlx/query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json +++ b/backend/.sqlx/query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n filters,\n auto_offset_reset,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), $13, $14, $15\n )\n ", + "query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n filters,\n auto_offset_reset,\n auto_commit,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, now(), $14, $15, $16\n )\n ", "describe": { "columns": [], "parameters": { @@ -12,6 +12,7 @@ "VarcharArray", "JsonbArray", "Varchar", + "Bool", "Varchar", "Bool", { @@ -35,5 +36,5 @@ }, "nullable": [] }, - "hash": "4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f" + "hash": "5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f" } diff --git a/backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json b/backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json new file mode 100644 index 0000000000..31f2767fe8 --- /dev/null +++ b/backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM kafka_pending_commits WHERE id = ANY($1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9" +} diff --git a/backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json b/backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json new file mode 100644 index 0000000000..7415b311f1 --- /dev/null +++ b/backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE kafka_trigger SET reset_offset = true, server_id = NULL WHERE workspace_id = $1 AND path = $2 RETURNING true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9" +} diff --git a/backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json b/backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json new file mode 100644 index 0000000000..238977d563 --- /dev/null +++ b/backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE kafka_trigger SET reset_offset = false WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b" +} diff --git a/backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json b/backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json new file mode 100644 index 0000000000..5797d761e9 --- /dev/null +++ b/backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO kafka_pending_commits (workspace_id, kafka_trigger_path, topic, partition, \"offset\")\n VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 23e6f2a4b1..b10179c658 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16870,7 +16870,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16950,7 +16950,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "serde", @@ -16980,7 +16980,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "serde-wasm-bindgen", diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 18e2279e37..a048a9b463 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -85bcbfc8e952842c555156d1050b2a024e7e37a3 \ No newline at end of file +fcd3ea52b0cc94fbe1159baf662a38da947456de \ No newline at end of file diff --git a/backend/migrations/20260312000000_kafka_auto_commit.down.sql b/backend/migrations/20260312000000_kafka_auto_commit.down.sql new file mode 100644 index 0000000000..58d1ad2659 --- /dev/null +++ b/backend/migrations/20260312000000_kafka_auto_commit.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS kafka_pending_commits; +ALTER TABLE kafka_trigger DROP COLUMN auto_commit; diff --git a/backend/migrations/20260312000000_kafka_auto_commit.up.sql b/backend/migrations/20260312000000_kafka_auto_commit.up.sql new file mode 100644 index 0000000000..643e2ce1f3 --- /dev/null +++ b/backend/migrations/20260312000000_kafka_auto_commit.up.sql @@ -0,0 +1,14 @@ +ALTER TABLE kafka_trigger ADD COLUMN auto_commit BOOLEAN NOT NULL DEFAULT TRUE; + +CREATE TABLE kafka_pending_commits ( + id BIGSERIAL PRIMARY KEY, + workspace_id VARCHAR(50) NOT NULL, + kafka_trigger_path VARCHAR(255) NOT NULL, + topic VARCHAR(255) NOT NULL, + partition INTEGER NOT NULL, + "offset" BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, kafka_trigger_path) REFERENCES kafka_trigger(workspace_id, path) ON DELETE CASCADE +); + +CREATE INDEX idx_kafka_pending_commits_trigger ON kafka_pending_commits (workspace_id, kafka_trigger_path); diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 3ece1ce7ef..2f86dcb322 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -109,7 +109,9 @@ job_result_stream_v2: job_id(uuid), workspace_id(text), stream(text), idx(int) job_settings: job_id(uuid), runnable_settings(bigint) job_stats: workspace_id(char), job_id(uuid), metric_id(char), metric_name(char), metric_kind(metric_kind), scalar_int(int), scalar_float(float), timestamps(ts), timeseries_int(int[]), timeseries_float(float[]) FK: (workspace_id) -> workspace(id) -kafka_trigger: path(char), kafka_resource_path(char), topics(char), group_id(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), filters(jsonb[]) +kafka_pending_commits: id(bigint), workspace_id(char), kafka_trigger_path(char), topic(char), partition(int), offset(bigint), created_at(ts) + FK: (workspace_id, kafka_trigger_path) -> kafka_trigger(workspace_id, path) +kafka_trigger: path(char), kafka_resource_path(char), topics(char), group_id(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), filters(jsonb[]), auto_commit(bool) log_file: hostname(char), log_ts(ts), ok_lines(bigint), err_lines(bigint), mode(log_mode), worker_group(char), file_path(char), json_fmt(bool) magic_link: email(char), token(char), expiration(ts) mcp_oauth_client: mcp_server_url(text), client_id(text), client_secret(text), client_secret_expires_at(ts), token_endpoint(text), created_at(ts) diff --git a/backend/windmill-api-integration-tests/tests/trigger_e2e.rs b/backend/windmill-api-integration-tests/tests/trigger_e2e.rs index 3b0e32749a..1d4f122bcc 100644 --- a/backend/windmill-api-integration-tests/tests/trigger_e2e.rs +++ b/backend/windmill-api-integration-tests/tests/trigger_e2e.rs @@ -251,7 +251,8 @@ async fn test_websocket_e2e(db: Pool) -> anyhow::Result<()> { "test-workspace", "test-user", "test@windmill.dev", - &[json!({"type": "RawMessage", "content": "hello from e2e test"})] as &[serde_json::Value], + &[json!({"type": "RawMessage", "content": "hello from e2e test"})] + as &[serde_json::Value], ) .execute(&db) .await?; @@ -302,9 +303,11 @@ async fn test_postgres_e2e(db: Pool) -> anyhow::Result<()> { sqlx::query("CREATE TABLE test_trigger_table (id serial PRIMARY KEY, data text)") .execute(&db) .await?; - sqlx::query(&format!("CREATE PUBLICATION {pub_name} FOR TABLE test_trigger_table")) - .execute(&db) - .await?; + sqlx::query(&format!( + "CREATE PUBLICATION {pub_name} FOR TABLE test_trigger_table" + )) + .execute(&db) + .await?; sqlx::query(&format!( "SELECT pg_create_logical_replication_slot('{slot_name}', 'pgoutput')" )) @@ -313,10 +316,9 @@ async fn test_postgres_e2e(db: Pool) -> anyhow::Result<()> { // Extract the test DB name from the pool so the resource points here, // not at the main windmill database. - let test_db_name: String = - sqlx::query_scalar("SELECT current_database()") - .fetch_one(&db) - .await?; + let test_db_name: String = sqlx::query_scalar("SELECT current_database()") + .fetch_one(&db) + .await?; insert_resource( &db, diff --git a/backend/windmill-api-integration-tests/tests/triggers.rs b/backend/windmill-api-integration-tests/tests/triggers.rs index 46854d566e..b55f24397c 100644 --- a/backend/windmill-api-integration-tests/tests/triggers.rs +++ b/backend/windmill-api-integration-tests/tests/triggers.rs @@ -214,12 +214,9 @@ async fn test_capture_delete(db: Pool) -> anyhow::Result<()> { .execute(&db) .await?; - let count = sqlx::query_scalar!( - "SELECT COUNT(*) FROM capture WHERE id = $1", - id, - ) - .fetch_one(&db) - .await?; + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM capture WHERE id = $1", id,) + .fetch_one(&db) + .await?; assert_eq!(count, Some(0)); @@ -385,7 +382,10 @@ async fn test_capture_api_list_captures(db: Pool) -> anyhow::Result<() .send() .await?; - assert!(response.status().is_success(), "list captures should succeed"); + assert!( + response.status().is_success(), + "list captures should succeed" + ); let captures: Vec = response.json().await?; assert_eq!(captures.len(), 3); @@ -480,12 +480,9 @@ async fn test_capture_api_delete(db: Pool) -> anyhow::Result<()> { assert!(response.status().is_success(), "delete should succeed"); - let count = sqlx::query_scalar!( - "SELECT COUNT(*) FROM capture WHERE id = $1", - id, - ) - .fetch_one(&db) - .await?; + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM capture WHERE id = $1", id,) + .fetch_one(&db) + .await?; assert_eq!(count, Some(0)); @@ -933,7 +930,8 @@ async fn test_kafka_trigger_insert(db: Pool) -> anyhow::Result<()> { let trigger = sqlx::query!( r#" - SELECT kafka_resource_path, topics, group_id, mode AS "mode: String" + SELECT kafka_resource_path, topics, group_id, mode AS "mode: String", + auto_offset_reset, auto_commit, reset_offset FROM kafka_trigger WHERE workspace_id = $1 AND path = $2 "#, @@ -947,6 +945,50 @@ async fn test_kafka_trigger_insert(db: Pool) -> anyhow::Result<()> { assert_eq!(trigger.topics, vec!["topic-a", "topic-b"]); assert_eq!(trigger.group_id, "my-consumer-group"); assert_eq!(trigger.mode, "enabled"); + assert_eq!(trigger.auto_offset_reset, "latest"); + assert_eq!(trigger.auto_commit, true); + assert_eq!(trigger.reset_offset, false); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_kafka_trigger_insert_auto_commit_disabled(db: Pool) -> anyhow::Result<()> { + sqlx::query!( + r#" + INSERT INTO kafka_trigger ( + path, kafka_resource_path, topics, group_id, script_path, + is_flow, workspace_id, edited_by, email, auto_commit + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#, + "f/test/kafka_trigger_no_commit", + "u/admin/kafka_resource", + &["topic-c"] as &[&str], + "my-consumer-group-2", + "f/test/kafka_handler", + false, + "test-workspace", + "test-user", + "test@windmill.dev", + false, + ) + .execute(&db) + .await?; + + let trigger = sqlx::query!( + r#" + SELECT auto_commit + FROM kafka_trigger + WHERE workspace_id = $1 AND path = $2 + "#, + "test-workspace", + "f/test/kafka_trigger_no_commit", + ) + .fetch_one(&db) + .await?; + + assert_eq!(trigger.auto_commit, false); Ok(()) } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 9bb25f5589..9ec79f84d4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -12017,6 +12017,39 @@ paths: "200": description: kafka trigger offsets reset successfully + /w/{workspace}/kafka_triggers/commit_offsets/{path}: + post: + summary: commit kafka offsets for a specific trigger + operationId: commitKafkaOffsets + tags: + - kafka_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + requestBody: + description: offsets to commit + required: true + content: + application/json: + schema: + type: object + properties: + topic: + type: string + partition: + type: integer + format: int32 + offset: + type: integer + format: int64 + required: + - topic + - partition + - offset + responses: + "200": + description: kafka offsets committed successfully + /w/{workspace}/nats_triggers/create: post: summary: create nats trigger @@ -22098,6 +22131,10 @@ components: - earliest default: latest description: "Initial offset behavior when consumer group has no committed offset. 'latest' starts from new messages only, 'earliest' starts from the beginning." + auto_commit: + type: boolean + default: true + description: "When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint." server_id: type: string description: ID of the server currently handling this trigger (internal) @@ -22165,6 +22202,10 @@ components: - earliest default: latest description: "Initial offset behavior when consumer group has no committed offset." + auto_commit: + type: boolean + default: true + description: "When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint." mode: $ref: "#/components/schemas/TriggerMode" error_handler_path: @@ -22224,6 +22265,10 @@ components: - earliest default: latest description: "Initial offset behavior when consumer group has no committed offset." + auto_commit: + type: boolean + default: true + description: "When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint." path: type: string description: The unique path identifier for this trigger diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index bcc287ec04..277504b481 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -1012,6 +1012,7 @@ async fn http_payload( .to_v2_preprocessor_args( &http_trigger_config.route_path, &route_path, + "", ¶ms, headers, query, diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 9d95ead840..fcb883f089 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -468,6 +468,7 @@ async fn route_job( .to_args_from_format( &trigger.route_path, &called_path, + &trigger.path, ¶ms, runnable_format, trigger.wrap_body, diff --git a/backend/windmill-api/src/triggers/http/http_trigger_args.rs b/backend/windmill-api/src/triggers/http/http_trigger_args.rs index 4866f9be34..7df1e9f200 100644 --- a/backend/windmill-api/src/triggers/http/http_trigger_args.rs +++ b/backend/windmill-api/src/triggers/http/http_trigger_args.rs @@ -68,6 +68,7 @@ struct HttpTriggerPreprocessorEvent<'a> { kind: String, route: &'a str, path: &'a str, + trigger_path: &'a str, body: Box, raw_string: Option, params: &'a HashMap, @@ -117,6 +118,7 @@ impl HttpTriggerArgs { self, route_path: &str, called_path: &str, + trigger_path: &str, params: &HashMap, format: RunnableFormat, wrap_body: bool, @@ -126,7 +128,14 @@ impl HttpTriggerArgs { match format { RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V2 } => { // we don't care about wrap_body in v2 - self.to_v2_preprocessor_args(route_path, called_path, params, headers, query) + self.to_v2_preprocessor_args( + route_path, + called_path, + trigger_path, + params, + headers, + query, + ) } RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V1 } => self .to_v1_preprocessor_args( @@ -177,6 +186,7 @@ impl HttpTriggerArgs { self, route_path: &str, called_path: &str, + trigger_path: &str, params: &HashMap, headers: HashMap>, query: HashMap>, @@ -193,6 +203,7 @@ impl HttpTriggerArgs { method: (&self.0.metadata.method).try_into()?, route: route_path, path: called_path, + trigger_path, params, }), ); diff --git a/backend/windmill-trigger-websocket/src/listener.rs b/backend/windmill-trigger-websocket/src/listener.rs index f6fba882a8..8aab61d9c6 100644 --- a/backend/windmill-trigger-websocket/src/listener.rs +++ b/backend/windmill-trigger-websocket/src/listener.rs @@ -322,7 +322,7 @@ impl Listener for WebsocketTrigger { db: &DB, listening_trigger: &ListeningTrigger, payload: Self::Payload, - trigger_info: HashMap>, + mut trigger_info: HashMap>, extra: Option, ) -> Result<()> { let ListeningTrigger { @@ -338,6 +338,7 @@ impl Listener for WebsocketTrigger { let WebsocketConfig { url, .. } = trigger_config; + trigger_info.insert("trigger_path".to_string(), to_raw_value(path)); let args = WebsocketTrigger::build_job_args( &script_path, *is_flow, diff --git a/backend/windmill-trigger/src/listener.rs b/backend/windmill-trigger/src/listener.rs index d09bb5e55a..04e1df102d 100644 --- a/backend/windmill-trigger/src/listener.rs +++ b/backend/windmill-trigger/src/listener.rs @@ -21,6 +21,7 @@ use windmill_common::{ jobs::JobTriggerKind, triggers::{TriggerKind, TriggerMetadata}, utils::report_critical_error, + worker::to_raw_value, DB, INSTANCE_NAME, }; @@ -467,9 +468,13 @@ pub trait Listener: TriggerCrud + TriggerJobArgs { db: &DB, listening_trigger: &ListeningTrigger, payload: Self::Payload, - trigger_info: HashMap>, + mut trigger_info: HashMap>, _extra: Option, ) -> Result<()> { + trigger_info.insert( + "trigger_path".to_string(), + to_raw_value(&listening_trigger.path), + ); let args = Self::build_job_args( &listening_trigger.script_path, listening_trigger.is_flow, @@ -552,6 +557,11 @@ pub trait Listener: TriggerCrud + TriggerJobArgs { return Ok(()); } + let mut trigger_info = trigger_info; + trigger_info.insert( + "trigger_path".to_string(), + to_raw_value(&listening_trigger.path), + ); let (main_args, preprocessor_args) = Self::build_capture_payloads(&payload, trigger_info); if let Err(err) = insert_capture_payload( db, diff --git a/frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte b/frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte new file mode 100644 index 0000000000..b261aa8ed7 --- /dev/null +++ b/frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte @@ -0,0 +1,28 @@ + + +{#if allBadges.length > 0} +
+ {#each allBadges as badge} + {badge.name} + {/each} +
+{/if} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index b40ef8e505..a33f14243b 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -25,6 +25,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { saveEmailTriggerFromCfg } from './utils' import { deepEqual } from 'fast-equals' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' @@ -370,7 +371,10 @@ />
-
+ {#snippet header()} + + {/snippet} +
@@ -390,6 +394,7 @@
+
{/if} {/snippet} diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 5e1b08d2c3..6a11d6180b 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -31,6 +31,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import Subsection from '$lib/components/Subsection.svelte' import Toggle from '$lib/components/Toggle.svelte' @@ -452,7 +453,12 @@ />
-
+ {#snippet header()} + + {/snippet} +
@@ -520,6 +526,7 @@
+
{/if} {/snippet} diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index a35227e081..272a0b707c 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -52,6 +52,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -695,6 +696,13 @@ {#if !is_static_website}
+ {#snippet header()} + + {/snippet}
@@ -909,6 +917,7 @@
+
{/if}
{/if} diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index ad5cce6f92..5f68f03705 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -6,12 +6,7 @@ import Path from '$lib/components/Path.svelte' import Required from '$lib/components/Required.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' - import { - KafkaTriggerService, - type ErrorHandler, - type Retry, - type TriggerMode - } from '$lib/gen' + import { KafkaTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' import Section from '$lib/components/Section.svelte' @@ -25,10 +20,13 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import TriggerFilters from '../TriggerFilters.svelte' + import Select from '$lib/components/select/Select.svelte' + import Toggle from '$lib/components/Toggle.svelte' interface Props { useDrawer?: boolean @@ -87,6 +85,7 @@ let kafkaResourcePath = $state('') let kafkaCfg: Record = $state({}) let autoOffsetReset = $state('latest') + let autoCommit = $state(true) let deploymentLoading = $state(false) let resetLoading = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') @@ -176,6 +175,7 @@ topics: nDefaultValues?.topics ?? [''] } autoOffsetReset = nDefaultValues?.auto_offset_reset ?? 'latest' + autoCommit = nDefaultValues?.auto_commit ?? true initialScriptPath = '' fixedScriptPath = fixedScriptPath_ ?? '' script_path = fixedScriptPath @@ -207,6 +207,7 @@ topics: cfg?.topics } autoOffsetReset = cfg?.auto_offset_reset ?? 'latest' + autoCommit = cfg?.auto_commit ?? true mode = cfg?.mode ?? 'enabled' extra_perms = cfg?.extra_perms can_write = canWrite(path, cfg?.extra_perms, $userStore) @@ -240,6 +241,7 @@ topics: kafkaCfg.topics, filters, auto_offset_reset: autoOffsetReset, + auto_commit: autoCommit, mode, extra_perms: extra_perms, error_handler_path, @@ -481,36 +483,85 @@ bind:kafkaCfgValid bind:kafkaResourcePath bind:kafkaCfg - bind:autoOffsetReset {path} {can_write} showTestingBadge={isEditor} /> - {#if edit && can_write} - - {/if} - - -
-
+ {#snippet header()} + 0 } + ]} + /> + {/snippet} +
+ -
diff --git a/frontend/src/lib/components/triggers/kafka/utils.ts b/frontend/src/lib/components/triggers/kafka/utils.ts index 16e065093d..12ee49ac24 100644 --- a/frontend/src/lib/components/triggers/kafka/utils.ts +++ b/frontend/src/lib/components/triggers/kafka/utils.ts @@ -25,6 +25,7 @@ export async function saveKafkaTriggerFromCfg( topics: cfg.topics, filters: cfg.filters ?? [], auto_offset_reset: cfg.auto_offset_reset ?? 'latest', + auto_commit: cfg.auto_commit ?? true, ...errorHandlerAndRetries } try { diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index 18148fdbab..df5c7c21e1 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -29,6 +29,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import Toggle from '$lib/components/Toggle.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' @@ -469,7 +470,13 @@ />
-
+ {#snippet header()} + + {/snippet} +
@@ -606,6 +613,7 @@
+
{/if} {/snippet} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index 8c7fbb846c..a3c02c57dc 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -19,6 +19,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -452,7 +453,10 @@ />
-
+ {#snippet header()} + + {/snippet} +
@@ -472,6 +476,7 @@
+
{/if} {/snippet} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index cd176ac51c..5ffe489a22 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -36,6 +36,7 @@ import TestingBadge from '../testingBadge.svelte' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { fade } from 'svelte/transition' import MultiSelect from '$lib/components/select/MultiSelect.svelte' import { safeSelectItems } from '$lib/components/select/utils.svelte' @@ -854,7 +855,10 @@
-
+ {#snippet header()} + + {/snippet} +
@@ -874,6 +878,7 @@
+
{/if} {/snippet} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 4aca94c4b5..55f825b40c 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -1,5 +1,6 @@ -
+
Ducklake
diff --git a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte index 44b9d5d1d3..ef46189444 100644 --- a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte @@ -102,19 +102,6 @@ let hasUnsavedChanges = $derived.by(() => { return !deepEqual(s3ResourceSettings, s3ResourceSavedSettings) }) - - let volumeStorageItems: { value: string; label: string }[] = $derived.by(() => { - const items: { value: string; label: string }[] = [{ value: '', label: 'Disabled' }] - if (!emptyString(s3ResourceSettings.resourcePath)) { - items.push({ value: 'primary', label: 'Primary storage' }) - } - for (const [name, s] of s3ResourceSettings.secondaryStorage ?? []) { - if (!emptyString(s.resourcePath)) { - items.push({ value: name, label: name }) - } - } - return items - }) @@ -236,18 +223,18 @@ class="cursor-not-allowed" > {#snippet trigger()} - + - + {/snippet} {#snippet content()} - + {#if emptyString(tableRow[1].resourcePath)} Please select a storage resource {:else if isDirty(tableRow[0])} Please save your changes {/if} - + {/snippet} {:else} @@ -330,25 +317,6 @@ -
- -
- s3ResourceSettings.volumeStorage ?? '', + (v) => { + s3ResourceSettings.volumeStorage = v || undefined + } + } + /> +
+ + onDiscard?.()} + saveLabel="Save volume storage settings" + /> + {:else} + + You need to configure a workspace object storage before you can use volumes. + + + {/if} +{/if} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 8857b170c5..593a361b57 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -51,6 +51,7 @@ import ConnectionSection from '$lib/components/ConnectionSection.svelte' import AISettings from '$lib/components/workspaceSettings/AISettings.svelte' import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte' + import VolumeStorageSettings from '$lib/components/workspaceSettings/VolumeStorageSettings.svelte' import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte' import { untrack } from 'svelte' import { getHandlerType } from '$lib/components/triggers/utils' @@ -295,6 +296,8 @@ | 'ai' | 'windmill_data_tables' | 'windmill_lfs' + | 'volume_storage' + | 'ducklake' | 'git_sync' | 'default_app' | 'native_triggers' @@ -844,22 +847,40 @@ // Function to check if there are unsaved changes in storage settings function getStorageSettingsInitialAndModifiedValues() { - const savedValue = { - s3ResourceSettings: s3ResourceSavedSettings, - ducklakeSettings: ducklakeSavedSettings + return { + savedValue: { s3ResourceSettings: s3ResourceSavedSettings }, + modifiedValue: { s3ResourceSettings: s3ResourceSettings } } - - const modifiedValue = { - s3ResourceSettings: s3ResourceSettings, - ducklakeSettings: ducklakeSettings - } - - return { savedValue, modifiedValue } } // Function to discard unsaved storage settings changes function discardStorageSettingsChanges() { s3ResourceSettings = clone(s3ResourceSavedSettings) + } + + // Function to check if there are unsaved changes in volume storage settings + function getVolumeStorageInitialAndModifiedValues() { + return { + savedValue: { volumeStorage: s3ResourceSavedSettings.volumeStorage }, + modifiedValue: { volumeStorage: s3ResourceSettings.volumeStorage } + } + } + + // Function to discard unsaved volume storage changes + function discardVolumeStorageChanges() { + s3ResourceSettings.volumeStorage = s3ResourceSavedSettings.volumeStorage + } + + // Function to check if there are unsaved changes in ducklake settings + function getDucklakeSettingsInitialAndModifiedValues() { + return { + savedValue: { ducklakeSettings: ducklakeSavedSettings }, + modifiedValue: { ducklakeSettings: ducklakeSettings } + } + } + + // Function to discard unsaved ducklake settings changes + function discardDucklakeSettingsChanges() { ducklakeSettings = clone(ducklakeSavedSettings) } @@ -998,6 +1019,10 @@ return getAiSettingsInitialAndModifiedValues() case 'windmill_lfs': return getStorageSettingsInitialAndModifiedValues() + case 'volume_storage': + return getVolumeStorageInitialAndModifiedValues() + case 'ducklake': + return getDucklakeSettingsInitialAndModifiedValues() case 'deploy_to': return getDeploySettingsInitialAndModifiedValues() case 'webhook': @@ -1038,6 +1063,12 @@ case 'windmill_lfs': discardStorageSettingsChanges() break + case 'volume_storage': + discardVolumeStorageChanges() + break + case 'ducklake': + discardDucklakeSettingsChanges() + break case 'deploy_to': discardDeploySettingsChanges() break @@ -1178,6 +1209,18 @@ label: 'Object storage (S3)', aiId: 'workspace-settings-windmill-lfs', aiDescription: 'Object Storage (S3) workspace settings' + }, + { + id: 'volume_storage', + label: 'Volumes', + aiId: 'workspace-settings-volume-storage', + aiDescription: 'Volume storage workspace settings' + }, + { + id: 'ducklake', + label: 'Ducklake', + aiId: 'workspace-settings-ducklake', + aiDescription: 'Ducklake workspace settings' } ] }, @@ -1811,6 +1854,18 @@ export async function main( s3ResourceSettings = clone(s3ResourceSavedSettings) }} /> + {:else if tab == 'volume_storage'} + { + s3ResourceSavedSettings = clone(s3ResourceSettings) + }} + onDiscard={() => { + s3ResourceSettings = clone(s3ResourceSavedSettings) + }} + /> + {:else if tab == 'ducklake'} Date: Fri, 13 Mar 2026 07:08:33 +0100 Subject: [PATCH 20/76] add a hint to commit message on git sync for renames (#8343) * add a hint to commit message on git sync for renames * chore: update ee-repo-ref to 344e2aa60d8cafe08b8c57445d2f9555bad7625a This commit updates the EE repository reference after PR #458 was merged in windmill-ee-private. Previous ee-repo-ref: ad4c90cc96ad1b4511cba6b6ee28351895882156 New ee-repo-ref: 344e2aa60d8cafe08b8c57445d2f9555bad7625a Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index a048a9b463..ee34a47736 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -fcd3ea52b0cc94fbe1159baf662a38da947456de \ No newline at end of file +344e2aa60d8cafe08b8c57445d2f9555bad7625a From 4c2c165a5b757bd5f2f49074bb290407bce3b2fb Mon Sep 17 00:00:00 2001 From: Pyra <92104930+pyranota@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:10:28 +0100 Subject: [PATCH 21/76] feat(cli): add unified generate-metadata command (#8335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add unified generate-metadata command - Add generate-metadata command that calls script, flow, and app handlers - Export generateLocks from flow.ts and generateMetadata from script.ts - Add deprecation warnings to individual metadata commands * feat(cli): improve unified generate-metadata command - Use internal handlers for single-pass collection of stale items - Add --dry-run flag to show what would be updated - Fix WASM parser init deprecation warning - Add comprehensive tests for all flags - Match original handler behavior for per-item messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat(cli): add skip flags and comprehensive tests for generate-metadata - Add --skip-scripts, --skip-flows, --skip-apps flags for granular control - --schema-only now properly skips flows and apps (they only have locks) - Dynamic "Checking X, Y, Z..." message based on what's being processed - Show warning when all types are skipped - Add comprehensive tests for all flags: - --dry-run shows stale items without updating - --schema-only only processes scripts - --skip-scripts, --skip-flows, --skip-apps work correctly - skipping all types shows warning - 'All metadata up-to-date' when nothing to update 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * improve output Signed-off-by: pyranota * refactor(cli): add shared test fixtures with cross-links Co-Authored-By: Claude Opus 4.5 * feat(cli): add folder argument to generate-metadata command Co-Authored-By: Claude Opus 4.5 --------- Signed-off-by: pyranota Co-authored-by: Claude Opus 4.5 --- cli/src/commands/app/app.ts | 3 + cli/src/commands/app/app_metadata.ts | 18 +- cli/src/commands/flow/flow.ts | 5 +- cli/src/commands/flow/flow_metadata.ts | 10 +- .../generate-metadata/generate-metadata.ts | 332 +++++++++++ cli/src/commands/script/script.ts | 5 +- cli/src/main.ts | 2 + cli/src/utils/metadata.ts | 4 +- cli/test/sync_pull_push.test.ts | 23 +- cli/test/test_backend.ts | 13 + cli/test/test_fixtures.ts | 531 ++++++++++++++++++ cli/test/unified_generate_metadata.test.ts | 451 +++++++++++++++ 12 files changed, 1378 insertions(+), 19 deletions(-) create mode 100644 cli/src/commands/generate-metadata/generate-metadata.ts create mode 100644 cli/test/test_fixtures.ts create mode 100644 cli/test/unified_generate_metadata.test.ts diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 3aa8305542..f048aac616 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -275,6 +275,9 @@ const command = new Command() "Default TypeScript runtime (bun or deno)" ) .action(async (opts: any, appFolder: string | undefined) => { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); const { generateLocksCommand } = await import("./app_metadata.ts"); await generateLocksCommand(opts, appFolder); }); diff --git a/cli/src/commands/app/app_metadata.ts b/cli/src/commands/app/app_metadata.ts index 3a28770225..78a3019566 100644 --- a/cli/src/commands/app/app_metadata.ts +++ b/cli/src/commands/app/app_metadata.ts @@ -157,7 +157,7 @@ export async function generateAppLocksInternal( return remote_path; } - if (Object.keys(filteredDeps).length > 0) { + if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) { log.info( (await blueColor())( `Found workspace dependencies (${workspaceDependenciesLanguages @@ -180,9 +180,11 @@ export async function generateAppLocksInternal( } if (changedScripts.length > 0) { - log.info( - `Recomputing locks of ${changedScripts.join(", ")} in ${appFolder}` - ); + if (!noStaleMessage) { + log.info( + `Recomputing locks of ${changedScripts.join(", ")} in ${appFolder}` + ); + } if (rawApp) { const runnablesPath = path.join(appFolder, APP_BACKEND_FOLDER); @@ -230,7 +232,7 @@ export async function generateAppLocksInternal( yamlStringify(appFile as Record, yamlOptions) ); } - } else { + } else if (!noStaleMessage) { log.info(colors.gray(`No scripts changed in ${appFolder}`)); } } @@ -246,7 +248,9 @@ export async function generateAppLocksInternal( for (const [scriptPath, hash] of Object.entries(hashes)) { await updateMetadataGlobalLock(appFolder, hash, scriptPath); } - log.info(colors.green(`App ${remote_path} lockfiles updated`)); + if (!noStaleMessage) { + log.info(colors.green(`App ${remote_path} lockfiles updated`)); + } } /** @@ -767,7 +771,7 @@ export async function inferRunnableSchemaFromFile( } } -function getAppFolders(elems: Record, extension: string) { +export function getAppFolders(elems: Record, extension: string) { return Object.keys(elems) .filter((p) => p.endsWith(SEP + extension)) .map((p) => p.substring(0, p.length - (SEP + extension).length)); diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index f0974cf354..3b01385fb9 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -308,12 +308,15 @@ async function preview( } } -async function generateLocks( +export async function generateLocks( opts: GlobalOptions & { yes?: boolean; } & SyncOptions, folder: string | undefined ) { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); const workspace = await resolveWorkspace(opts); await requireLogin(opts); opts = await mergeConfigWithConfigFile(opts); diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index d52d05a3ed..2e48efaa10 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -98,7 +98,7 @@ export async function generateFlowLockInternal( return remote_path; } - if (Object.keys(filteredDeps).length > 0) { + if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) { log.info( (await blueColor())( `Found workspace dependencies (${workspaceDependenciesLanguages @@ -121,7 +121,9 @@ export async function generateFlowLockInternal( } } - log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); + if (!noStaleMessage) { + log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); + } const fileReader = async (path: string) => await readFile(folder + SEP + path, "utf-8"); await replaceInlineScripts( flowValue.value.modules, @@ -180,7 +182,9 @@ export async function generateFlowLockInternal( for (const [path, hash] of Object.entries(hashes)) { await updateMetadataGlobalLock(folder, hash, path); } - log.info(colors.green(`Flow ${remote_path} lockfiles updated`)); + if (!noStaleMessage) { + log.info(colors.green(`Flow ${remote_path} lockfiles updated`)); + } } /** diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts new file mode 100644 index 0000000000..0d7435856c --- /dev/null +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -0,0 +1,332 @@ +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { colors } from "@cliffy/ansi/colors"; +import { sep as SEP } from "node:path"; +import { GlobalOptions } from "../../types.ts"; +import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts"; +import { resolveWorkspace } from "../../core/context.ts"; +import { requireLogin } from "../../core/auth.ts"; +import * as log from "../../core/log.ts"; +import { + generateScriptMetadataInternal, + getRawWorkspaceDependencies, +} from "../../utils/metadata.ts"; +import { generateFlowLockInternal } from "../flow/flow_metadata.ts"; +import { generateAppLocksInternal, getAppFolders } from "../app/app_metadata.ts"; +import { + elementsToMap, + FSFSElement, + ignoreF, +} from "../sync/sync.ts"; +import { exts } from "../script/script.ts"; +import { isFlowPath, isAppPath } from "../../utils/resource_folders.ts"; +import { listSyncCodebases } from "../../utils/codebase.ts"; + +interface StaleItem { + type: "script" | "flow" | "app"; + path: string; + folder: string; + isRawApp?: boolean; +} + +async function generateMetadata( + opts: GlobalOptions & { + yes?: boolean; + lockOnly?: boolean; + schemaOnly?: boolean; + dryRun?: boolean; + skipScripts?: boolean; + skipFlows?: boolean; + skipApps?: boolean; + } & SyncOptions, + folder?: string +) { + if (folder === "") { + folder = undefined; + } + + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + opts = await mergeConfigWithConfigFile(opts); + + const rawWorkspaceDependencies = await getRawWorkspaceDependencies(); + const codebases = await listSyncCodebases(opts); + const ignore = await ignoreF(opts); + + const staleItems: StaleItem[] = []; + + // --schema-only implies skipping flows and apps (they only have locks, no schemas) + const skipScripts = opts.skipScripts ?? false; + const skipFlows = opts.skipFlows ?? opts.schemaOnly ?? false; + const skipApps = opts.skipApps ?? opts.schemaOnly ?? false; + + const checking: string[] = []; + if (!skipScripts) checking.push("scripts"); + if (!skipFlows) checking.push("flows"); + if (!skipApps) checking.push("apps"); + + if (checking.length === 0) { + log.info(colors.yellow("Nothing to check (all types skipped)")); + return; + } + + log.info(colors.gray(`Checking ${checking.join(", ")}...`)); + + // === Collect stale scripts === + if (!skipScripts) { + // TODO: run elementsToMap only once but for all runnable types. + const scriptElems = await elementsToMap( + await FSFSElement(process.cwd(), codebases, false), + (p, isD) => { + return ( + (!isD && !exts.some((ext) => p.endsWith(ext))) || + ignore(p, isD) || + isFlowPath(p) || + isAppPath(p) + ); + }, + false, + {} + ); + + for (const e of Object.keys(scriptElems)) { + const candidate = await generateScriptMetadataInternal( + e, + workspace, + opts, + true, // dryRun + true, // noStaleMessage + rawWorkspaceDependencies, + codebases, + false + ); + if (candidate) { + staleItems.push({ type: "script", path: candidate, folder: e }); + } + } + } + + // === Collect stale flows === + if (!skipFlows) { + const flowElems = Object.keys( + await elementsToMap( + await FSFSElement(process.cwd(), [], true), + (p, isD) => { + return ( + ignore(p, isD) || + (!isD && + !p.endsWith(SEP + "flow.yaml") && + !p.endsWith(SEP + "flow.json")) + ); + }, + false, + {} + ) + ).map((x) => x.substring(0, x.lastIndexOf(SEP))); + + for (const folder of flowElems) { + const candidate = await generateFlowLockInternal( + folder, + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "flow", path: candidate, folder }); + } + } + } + + // === Collect stale apps === + if (!skipApps) { + const elems = await elementsToMap( + await FSFSElement(process.cwd(), [], true), + (p, isD) => { + return ( + ignore(p, isD) || + (!isD && + !p.endsWith(SEP + "raw_app.yaml") && + !p.endsWith(SEP + "app.yaml")) + ); + }, + false, + {} + ); + + const rawAppFolders = getAppFolders(elems, "raw_app.yaml"); + const appFolders = getAppFolders(elems, "app.yaml"); + + for (const appFolder of rawAppFolders) { + const candidate = await generateAppLocksInternal( + appFolder, + true, // rawApp + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "app", path: candidate, folder: appFolder, isRawApp: true }); + } + } + + for (const appFolder of appFolders) { + const candidate = await generateAppLocksInternal( + appFolder, + false, // rawApp + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "app", path: candidate, folder: appFolder, isRawApp: false }); + } + } + } + + // === Filter by folder if specified === + let filteredItems = staleItems; + if (folder) { + // Strip trailing separator to match deprecated flow/app handler behavior + // (see generateFlowLockInternal line 64-66, generateAppLocksInternal line 109-110) + if (folder.endsWith(SEP)) { + folder = folder.substring(0, folder.length - 1); + } + filteredItems = staleItems.filter((item) => item.folder === folder || item.folder.startsWith(folder + SEP)); + } + + // === Show stale items and confirm === + if (filteredItems.length === 0) { + log.info(colors.green("All metadata up-to-date")); + return; + } + + // Group items by type for display + const scripts = filteredItems.filter((i) => i.type === "script"); + const flows = filteredItems.filter((i) => i.type === "flow"); + const apps = filteredItems.filter((i) => i.type === "app"); + + log.info(""); + log.info(`Found ${filteredItems.length} item(s) with stale metadata:`); + + if (scripts.length > 0) { + log.info(colors.gray(` Scripts (${scripts.length}):`)); + for (const item of scripts) { + log.info(colors.yellow(` ${item.path}`)); + } + } + if (flows.length > 0) { + log.info(colors.gray(` Flows (${flows.length}):`)); + for (const item of flows) { + log.info(colors.yellow(` ${item.path}`)); + } + } + if (apps.length > 0) { + log.info(colors.gray(` Apps (${apps.length}):`)); + for (const item of apps) { + log.info(colors.yellow(` ${item.path}`)); + } + } + + if (opts.dryRun) { + return; + } + + log.info(""); + + if ( + !opts.yes && + !(await Confirm.prompt({ + message: "Update metadata?", + default: true, + })) + ) { + return; + } + + log.info(""); + + // === Process all stale items with progress counter === + const total = filteredItems.length; + const maxWidth = `[${total}/${total}]`.length; + let current = 0; + + const formatProgress = (n: number) => { + const bracket = `[${n}/${total}]`; + return colors.gray(bracket.padEnd(maxWidth, " ")); + }; + + // Process scripts + for (const item of scripts) { + current++; + log.info(`${formatProgress(current)} script ${colors.cyan(item.path)}`); + await generateScriptMetadataInternal( + item.folder, + workspace, + opts, + false, // dryRun + true, // noStaleMessage - we handle output + rawWorkspaceDependencies, + codebases, + false + ); + } + + // Process flows + for (const item of flows) { + current++; + log.info(`${formatProgress(current)} flow ${colors.cyan(item.path)}`); + await generateFlowLockInternal( + item.folder, + false, // dryRun + workspace, + opts, + false, + true // noStaleMessage - we handle output + ); + } + // Process apps + for (const item of apps) { + current++; + log.info(`${formatProgress(current)} app ${colors.cyan(item.path)}`); + await generateAppLocksInternal( + item.folder, + item.isRawApp!, // rawApp + false, // dryRun + workspace, + opts, + false, + true // noStaleMessage - we handle output + ); + } + + log.info(""); + log.info(colors.green(`Done. Updated ${total} item(s).`)); +} + +const command = new Command() + .description("Generate metadata (locks, schemas) for all scripts, flows, and apps") + .arguments("[folder:string]") + .option("--yes", "Skip confirmation prompt") + .option("--dry-run", "Show what would be updated without making changes") + .option("--lock-only", "Re-generate only the lock files") + .option("--schema-only", "Re-generate only script schemas (skips flows and apps)") + .option("--skip-scripts", "Skip processing scripts") + .option("--skip-flows", "Skip processing flows") + .option("--skip-apps", "Skip processing apps") + .option( + "-i --includes ", + "Comma separated patterns to specify which files to include" + ) + .option( + "-e --excludes ", + "Comma separated patterns to specify which files to exclude" + ) + .action(generateMetadata as any); + +export default command; diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 9c6f094b41..3a6556b34d 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -978,7 +978,7 @@ export type GlobalDeps = Map< Record >; -async function generateMetadata( +export async function generateMetadata( opts: GlobalOptions & { lockOnly?: boolean; schemaOnly?: boolean; @@ -986,6 +986,9 @@ async function generateMetadata( } & SyncOptions, scriptPath: string | undefined ) { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); log.info( "This command only works for workspace scripts, for flows inline scripts use `wmill flow generate-locks`" ); diff --git a/cli/src/main.ts b/cli/src/main.ts index f923ef38fd..d81570e784 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -39,6 +39,7 @@ import queues from "./commands/queues/queues.ts"; import dependencies from "./commands/dependencies/dependencies.ts"; import init from "./commands/init/init.ts"; import jobs from "./commands/jobs/jobs.ts"; +import generateMetadata from "./commands/generate-metadata/generate-metadata.ts"; import docs from "./commands/docs/docs.ts"; import { fetchVersion } from "./core/context.ts"; @@ -129,6 +130,7 @@ const command = new Command() .command("queues", queues) .command("dependencies", dependencies) .command("jobs", jobs) + .command("generate-metadata", generateMetadata) .command("docs", docs) .command("version --version", "Show version information") .action(async (opts: any) => { diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index 590c3d7d8e..76ff33247a 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -35,7 +35,7 @@ function loadParser(pkgName: string): Promise { const wasmPath = _require.resolve( `${pkgName}/windmill_parser_wasm_bg.wasm` ); - await mod.default(readFileSync(wasmPath)); + await mod.default({ module_or_path: readFileSync(wasmPath) }); return mod; })(); _parserCache.set(pkgName, p); @@ -223,7 +223,7 @@ export async function generateScriptMetadataInternal( return `${remotePath} (${language})`; } - if (!justUpdateMetadataLock) { + if (!justUpdateMetadataLock && !noStaleMessage) { log.info(colors.gray(`Generating metadata for ${scriptPath}`)); } diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index 2b062eb157..613de2ef82 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -3,6 +3,13 @@ * * Tests the sync pull and push functionality with a simulated filesystem * containing every kind of Windmill resource type. + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_fixtures.ts - Shared local fixtures (prefer using this module for new tests) + * @see test_backend.ts - API-based creation helpers (createTestApp, createTestResource, etc.) + * + * This file contains: Local fixtures (should migrate to test_fixtures.ts) + createRemoteScript + * If you add new helpers, update cross-links in the files above. */ import { expect, test, describe } from "bun:test"; @@ -37,10 +44,13 @@ import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path- // ============================================================================= // Test Fixtures - Every Type of Windmill Resource +// See file header for cross-links to related helpers. +// Consider migrating these to test_fixtures.ts for reuse across tests. // ============================================================================= /** - * Creates a mock script file structure + * Creates a mock script file structure. + * See file header for cross-links to related helpers. */ function createScriptFixture( name: string, @@ -89,7 +99,8 @@ kind: script } /** - * Creates a mock flow file structure + * Creates a mock flow file structure. + * See file header for cross-links to related helpers. */ function createFlowFixture(name: string): Record { const flowSuffix = getFolderSuffix("flow"); @@ -123,7 +134,8 @@ schema: } /** - * Creates a mock app file structure + * Creates a mock app file structure. + * See file header for cross-links to related helpers. */ function createAppFixture(name: string): Record { const appSuffix = getFolderSuffix("app"); @@ -151,7 +163,8 @@ policy: } /** - * Creates a mock raw_app file structure + * Creates a mock raw_app file structure. + * See file header for cross-links to related helpers. */ function createRawAppFixture(name: string): Record { const rawAppSuffix = getFolderSuffix("raw_app"); @@ -1920,7 +1933,7 @@ excludes: [] import type { TestBackend } from "./test_backend.ts"; -/** Create a script on the remote via API */ +/** Create a script on the remote via API. See file header for cross-links. */ async function createRemoteScript( backend: TestBackend, scriptPath: string, diff --git a/cli/test/test_backend.ts b/cli/test/test_backend.ts index 63c7f3078d..16fb79528d 100644 --- a/cli/test/test_backend.ts +++ b/cli/test/test_backend.ts @@ -19,6 +19,13 @@ * // ... * }); * }); + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_fixtures.ts - Local file fixtures (createLocalScript, createLocalFlow, etc.) + * @see sync_pull_push.test.ts - Local fixtures + createRemoteScript (API-based) + * + * This file contains: API-based creation helpers (createTestApp, createTestResource, etc.) + * If you add new helpers, update cross-links in the files above. */ import { CargoBackend, CargoBackendConfig } from "./cargo_backend.ts"; @@ -109,6 +116,7 @@ class CargoBackendAdapter implements TestBackend { return this.backend.apiRequest(path, options); } + /** Seeds test data via API calls. See file header for cross-links to related helpers. */ async seedTestData(): Promise { // Create test folder first await this.createTestFolder("test"); @@ -124,6 +132,7 @@ class CargoBackendAdapter implements TestBackend { await this.createTestApp("f/test/test_dashboard"); } + /** See file header for cross-links to related helpers. */ private async createTestApp(path: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/apps/create`, { method: "POST", @@ -156,6 +165,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestFolder(name: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/folders/create`, { method: "POST", @@ -172,6 +182,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestGroup(name: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/groups/create`, { method: "POST", @@ -189,6 +200,7 @@ class CargoBackendAdapter implements TestBackend { } + /** See file header for cross-links to related helpers. */ private async createTestResource(path: string, description: string): Promise { // First ensure the folder exists const folderPath = path.split("/").slice(0, 2).join("/"); // e.g., "f/test" @@ -228,6 +240,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestVariable(path: string, value: string): Promise { const response = await this.backend.apiRequest( `/api/w/${this.workspace}/variables/create`, diff --git a/cli/test/test_fixtures.ts b/cli/test/test_fixtures.ts new file mode 100644 index 0000000000..5f64869949 --- /dev/null +++ b/cli/test/test_fixtures.ts @@ -0,0 +1,531 @@ +/** + * Test Fixtures + * + * Shared helpers for creating test data (scripts, flows, apps, raw apps) in tests. + * + * Two types of helpers: + * - Fixture functions: Return data structures with paths and contents (no disk I/O) + * - Local creation functions: Create fixtures AND write them to disk + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_backend.ts - API-based creation helpers (createTestApp, createTestResource, etc.) + * @see sync_pull_push.test.ts - Local fixtures + createRemoteScript (API-based) + * + * This file contains: Shared local fixtures (createLocalScript, createLocalFlow, etc.) + * If you add new helpers, update cross-links in the files above. + * + * @example + * // Using fixtures (data only) + * const fixture = createScriptFixture("my_script", "bun"); + * + * // Using local creation (writes to disk) + * await createLocalScript(tempDir, "f/test", "my_script", "bun"); + * + * @keywords createLocal, local script, local flow, local app, raw app, fixture, test data + */ + +import { writeFile, mkdir } from "node:fs/promises"; +import { + getFolderSuffix, + getMetadataFileName, +} from "../src/utils/resource_folders.ts"; + +// ============================================================================= +// Fixture Types +// ============================================================================= + +export interface FileFixture { + path: string; + content: string; +} + +export interface ScriptFixture { + contentFile: FileFixture; + metadataFile: FileFixture; +} + +export interface FlowFixture { + metadata: FileFixture; + inlineScript: FileFixture; +} + +export interface AppFixture { + metadata: FileFixture; +} + +export interface RawAppFixture { + metadata: FileFixture; + indexHtml: FileFixture; + indexJs: FileFixture; + [key: string]: FileFixture; +} + +// ============================================================================= +// Script Fixtures +// ============================================================================= + +/** + * Creates a script fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * Use this when you need fine-grained control over the script structure. + * For simple cases, use {@link createLocalScript} instead. + * + * @param name - Script name (without extension) + * @param language - Script language + * @param content - Optional custom script content + * @returns Script fixture with content and metadata files + * + * @example + * const fixture = createScriptFixture("my_script", "bun"); + * const fixture = createScriptFixture("custom", "python3", "def main(): return 42"); + * + * @keywords script fixture, create script, local script + */ +export function createScriptFixture( + name: string, + language: "python3" | "deno" | "bun" | "bash" | "go" | "postgresql" = "bun", + content?: string +): ScriptFixture { + const extensions: Record = { + python3: ".py", + deno: ".ts", + bun: ".ts", + bash: ".sh", + go: ".go", + postgresql: ".sql", + }; + + const ext = extensions[language]; + const defaultContent: Record = { + python3: `def main():\n return "Hello from ${name}"`, + deno: `export async function main() {\n return "Hello from ${name}";\n}`, + bun: `export async function main() {\n return "Hello from ${name}";\n}`, + bash: `#!/bin/bash\necho "Hello from ${name}"`, + go: `package inner\n\nfunc main() string {\n return "Hello from ${name}"\n}`, + postgresql: `-- ${name}\nSELECT 'Hello from ${name}';`, + }; + + return { + contentFile: { + path: `${name}${ext}`, + content: content ?? defaultContent[language], + }, + metadataFile: { + path: `${name}.script.yaml`, + content: `summary: "${name} script" +description: "A ${language} script for testing" +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: {} + required: [] +is_template: false +lock: "" +kind: script +`, + }, + }; +} + +// ============================================================================= +// Flow Fixtures +// ============================================================================= + +/** + * Creates a flow fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: language, summary, description + * + * Use this when you need fine-grained control over the flow structure. + * For simple cases, use {@link createLocalFlow} instead. + * + * @param name - Flow name + * @param inlineScriptContent - Optional custom inline script content + * @returns Flow fixture with metadata and inline script + * + * @example + * const fixture = createFlowFixture("my_flow"); + * + * @keywords flow fixture, create flow, local flow + */ +export function createFlowFixture( + name: string, + inlineScriptContent?: string +): FlowFixture { + const flowSuffix = getFolderSuffix("flow"); + const metadataFile = getMetadataFileName("flow", "yaml"); + + const scriptContent = + inlineScriptContent ?? + `export async function main() {\n return "Hello from flow ${name}";\n}`; + + return { + metadata: { + path: `${name}${flowSuffix}/${metadataFile}`, + content: `summary: "${name} flow" +description: "A flow for testing" +value: + modules: + - id: a + value: + type: rawscript + content: | + ${scriptContent.split("\n").join("\n ")} + language: bun + input_transforms: {} +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: {} + required: [] +`, + }, + inlineScript: { + path: `${name}${flowSuffix}/a.inline_script.ts`, + content: scriptContent, + }, + }; +} + +// ============================================================================= +// App Fixtures +// ============================================================================= + +/** + * Creates an app fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: inlineScriptContent, summary, grid + * + * Use this when you need fine-grained control over the app structure. + * For simple cases, use {@link createLocalApp} instead. + * + * @param name - App name + * @returns App fixture with metadata + * + * @example + * const fixture = createAppFixture("my_app"); + * + * @keywords app fixture, create app, local app + */ +export function createAppFixture(name: string): AppFixture { + const appSuffix = getFolderSuffix("app"); + const metadataFile = getMetadataFileName("app", "yaml"); + + return { + metadata: { + path: `${name}${appSuffix}/${metadataFile}`, + content: `summary: "${name} app" +value: + type: app + grid: + - id: button1 + data: + type: buttoncomponent + componentInput: + type: runnable + runnable: + type: runnableByName + inlineScript: + content: | + export async function main() { + return "hello from app"; + } + language: bun + hiddenInlineScripts: [] + css: {} + norefreshbar: false +policy: + on_behalf_of: null + on_behalf_of_email: null + triggerables: {} + execution_mode: viewer +`, + }, + }; +} + +// ============================================================================= +// Raw App Fixtures +// ============================================================================= + +/** + * Creates a raw app fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: inlineScriptContent, htmlContent, jsContent + * + * Raw apps are React/frontend apps with separate inline scripts. + * Use this when you need fine-grained control over the raw app structure. + * For simple cases, use {@link createLocalRawApp} instead. + * + * @param name - Raw app name + * @returns Raw app fixture with metadata and frontend files + * + * @example + * const fixture = createRawAppFixture("my_raw_app"); + * + * @keywords raw app fixture, create raw app, local raw app, react app + */ +export function createRawAppFixture(name: string): RawAppFixture { + const rawAppSuffix = getFolderSuffix("raw_app"); + const metadataFile = getMetadataFileName("raw_app", "yaml"); + + return { + metadata: { + path: `${name}${rawAppSuffix}/${metadataFile}`, + content: `summary: "${name} raw app" +policy: + execution_mode: publisher + triggerables: {} +`, + }, + indexHtml: { + path: `${name}${rawAppSuffix}/index.html`, + content: ` + +${name} +
+`, + }, + indexJs: { + path: `${name}${rawAppSuffix}/index.tsx`, + content: `import React from 'react' +import { createRoot } from 'react-dom/client' + +const App = () =>

${name}

+ +const root = createRoot(document.getElementById('root')!) +root.render() +`, + }, + packageJson: { + path: `${name}${rawAppSuffix}/package.json`, + content: `{ + "dependencies": { + "react": "19.0.0", + "react-dom": "19.0.0" + } +}`, + }, + inlineScript: { + path: `${name}${rawAppSuffix}/inline_scripts/a.inline_script.ts`, + content: `export async function main(x: string) { + return x +} +`, + }, + inlineScriptLock: { + path: `${name}${rawAppSuffix}/inline_scripts/a.inline_script.lock`, + content: ``, + }, + }; +} + +// ============================================================================= +// Local Creation Functions (Fixture + Write to Disk) +// ============================================================================= + +/** + * Creates a script on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates a script fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Script name (without extension) + * @param language - Script language (default: "bun") + * @param content - Optional custom script content + * + * @example + * await createLocalScript(tempDir, "f/test", "my_script"); + * await createLocalScript(tempDir, "f/test", "custom", "python3", "def main(): return 42"); + * + * @keywords create local script, local script, write script, script on disk + */ +export async function createLocalScript( + tempDir: string, + path: string, + name: string, + language: "python3" | "deno" | "bun" | "bash" | "go" | "postgresql" = "bun", + content?: string +): Promise { + const fixture = createScriptFixture(name, language, content); + await mkdir(`${tempDir}/${path}`, { recursive: true }); + await writeFile( + `${tempDir}/${path}/${fixture.contentFile.path}`, + fixture.contentFile.content, + "utf-8" + ); + await writeFile( + `${tempDir}/${path}/${fixture.metadataFile.path}`, + fixture.metadataFile.content, + "utf-8" + ); +} + +/** + * Creates a flow on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates a flow fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Flow name + * @param inlineScriptContent - Optional custom inline script content + * + * @example + * await createLocalFlow(tempDir, "f/test", "my_flow"); + * + * @keywords create local flow, local flow, write flow, flow on disk + */ +export async function createLocalFlow( + tempDir: string, + path: string, + name: string, + inlineScriptContent?: string +): Promise { + const fixture = createFlowFixture(name, inlineScriptContent); + const flowDir = `${tempDir}/${path}/${name}${getFolderSuffix("flow")}`; + await mkdir(flowDir, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + await writeFile(fullPath, file.content, "utf-8"); + } +} + +/** + * Creates an app on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates an app fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - App name + * + * @example + * await createLocalApp(tempDir, "f/test", "my_app"); + * + * @keywords create local app, local app, write app, app on disk + */ +export async function createLocalApp( + tempDir: string, + path: string, + name: string +): Promise { + const fixture = createAppFixture(name); + const appDir = `${tempDir}/${path}/${name}${getFolderSuffix("app")}`; + await mkdir(appDir, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + await writeFile(fullPath, file.content, "utf-8"); + } +} + +/** + * Creates a raw app on the local filesystem. + * See file header for cross-links to related helpers. + * + * Raw apps are React/frontend apps with separate inline scripts. + * This is a convenience function that creates a raw app fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Raw app name + * + * @example + * await createLocalRawApp(tempDir, "f/test", "my_raw_app"); + * + * @keywords create local raw app, local raw app, write raw app, raw app on disk, react app + */ +export async function createLocalRawApp( + tempDir: string, + path: string, + name: string +): Promise { + const fixture = createRawAppFixture(name); + const rawAppSuffix = getFolderSuffix("raw_app"); + const appDir = `${tempDir}/${path}/${name}${rawAppSuffix}`; + await mkdir(`${appDir}/inline_scripts`, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + const dir = fullPath.substring(0, fullPath.lastIndexOf("/")); + await mkdir(dir, { recursive: true }); + await writeFile(fullPath, file.content, "utf-8"); + } +} + +// ============================================================================= +// Resource Fixtures (Variables, Resources, Schedules, etc.) +// ============================================================================= + +/** + * Creates a resource fixture. + * + * @keywords resource fixture, create resource + */ +export function createResourceFixture( + name: string, + resourceType: string, + value: Record +): FileFixture { + return { + path: `${name}.resource.yaml`, + content: `resource_type: "${resourceType}" +value: +${Object.entries(value) + .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`) + .join("\n")} +`, + }; +} + +/** + * Creates a variable fixture. + * + * @keywords variable fixture, create variable + */ +export function createVariableFixture( + name: string, + value: string, + isSecret: boolean = false +): FileFixture { + return { + path: `${name}.variable.yaml`, + content: `value: "${value}" +is_secret: ${isSecret} +description: "Variable ${name} for testing" +`, + }; +} + +/** + * Creates a schedule fixture. + * + * @keywords schedule fixture, create schedule + */ +export function createScheduleFixture( + name: string, + scriptPath: string, + schedule: string = "0 * * * *" +): FileFixture { + return { + path: `${name}.schedule.yaml`, + content: `path: "${name}" +schedule: "${schedule}" +script_path: "${scriptPath}" +is_flow: false +args: {} +enabled: true +timezone: "UTC" +`, + }; +} diff --git a/cli/test/unified_generate_metadata.test.ts b/cli/test/unified_generate_metadata.test.ts new file mode 100644 index 0000000000..5cd240dce9 --- /dev/null +++ b/cli/test/unified_generate_metadata.test.ts @@ -0,0 +1,451 @@ +/** + * Unified generate-metadata Command Tests + * + * Tests the new unified `generate-metadata` command that processes + * scripts, flows, and apps together. + */ + +import { expect, test, describe } from "bun:test"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { writeFile } from "node:fs/promises"; +import { + createLocalScript, + createLocalFlow, + createLocalApp, + createLocalRawApp, +} from "./test_fixtures.ts"; + +/** + * Helper to set up a workspace with wmill.yaml + */ +async function setupWorkspace(backend: any, tempDir: string, workspaceName: string) { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: workspaceName, + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +excludes: []`, "utf-8"); +} + +// ============================================================================= +// Main test: processes scripts, flows, and apps together +// ============================================================================= + +test("generate-metadata: processes scripts, flows, and apps together", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "unified_all_test"); + + // Create one of each type + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "unified_all_test" + ); + + expect(result.code).toEqual(0); + // Should find stale items + expect(result.stdout).toContain("Found"); + expect(result.stdout).toContain("stale metadata"); + }); +}); + +// ============================================================================= +// Flag tests +// ============================================================================= + +describe("generate-metadata flags", () => { + test("--includes filters to specific paths", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "includes_test"); + + // Create two scripts in different folders + await createLocalScript(tempDir, "f/included", "script_a"); + await createLocalScript(tempDir, "f/excluded", "script_b"); + + // Run with --includes to only process f/included + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "-i", "f/included/**"], + tempDir, + "includes_test" + ); + + expect(result.code).toEqual(0); + // Should only mention the included script + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).not.toContain("script_b"); + }); + }); + + test("--excludes filters out specific paths", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "excludes_test"); + + // Create two scripts + await createLocalScript(tempDir, "f/keep", "script_keep"); + await createLocalScript(tempDir, "f/skip", "script_skip"); + + // Run with --excludes to skip f/skip + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "-e", "f/skip/**"], + tempDir, + "excludes_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_keep"); + expect(output).not.toContain("script_skip"); + }); + }); + + test("--dry-run shows stale items without updating", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "dry_run_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // Run with --dry-run + const result = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "dry_run_test" + ); + + expect(result.code).toEqual(0); + // Should show stale items (Scripts section header) + expect(result.stdout).toContain("Scripts"); + expect(result.stdout).toContain("my_script"); + // Should NOT show "Done" (didn't actually update) + expect(result.stdout).not.toContain("Done"); + + // Run again without --dry-run to verify it would still be stale + const result2 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "dry_run_test" + ); + expect(result2.stdout).toContain("Scripts"); + }); + }); + + test("--lock-only only regenerates locks", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "lock_only_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--lock-only"], + tempDir, + "lock_only_test" + ); + + expect(result.code).toEqual(0); + }); + }); + + test("--schema-only only processes scripts (skips flows and apps)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "schema_only_test"); + + // Create one of each type + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--schema-only"], + tempDir, + "schema_only_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should show "Checking scripts..." only + expect(output).toContain("Checking scripts..."); + // Should find the script (Scripts section header) + expect(output).toContain("Scripts"); + // Should NOT find flows or apps + expect(output).not.toContain("Flows"); + expect(output).not.toContain("Apps"); + }); + }); + + test("--skip-scripts skips scripts", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_scripts_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-scripts"], + tempDir, + "skip_scripts_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should NOT contain script + expect(output).not.toContain("Scripts"); + // Should contain flow + expect(output).toContain("Flows"); + }); + }); + + test("--skip-flows skips flows", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_flows_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-flows"], + tempDir, + "skip_flows_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should contain script + expect(output).toContain("Scripts"); + // Should NOT contain flow + expect(output).not.toContain("Flows"); + }); + }); + + test("--skip-apps skips apps", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_apps_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-apps"], + tempDir, + "skip_apps_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should contain script + expect(output).toContain("Scripts"); + // Should NOT contain app + expect(output).not.toContain("Apps"); + }); + }); + + test("shows 'All metadata up-to-date' when nothing to update", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "uptodate_test"); + + // Create a script and run generate-metadata twice + await createLocalScript(tempDir, "f/test", "my_script"); + + // First run - generates metadata + await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "uptodate_test" + ); + + // Second run - should be up-to-date + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "uptodate_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); + + test("skipping all types shows warning", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_all_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--skip-scripts", "--skip-flows", "--skip-apps"], + tempDir, + "skip_all_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("Nothing to check"); + }); + }); +}); + +// ============================================================================= +// Folder argument tests +// ============================================================================= + +describe("generate-metadata folder argument", () => { + test("filters to specific script folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_script_test"); + + // Create scripts in different folders + await createLocalScript(tempDir, "f/included", "script_a"); + await createLocalScript(tempDir, "f/excluded", "script_b"); + + // Run with folder argument + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/script_a.ts"], + tempDir, + "folder_script_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).not.toContain("script_b"); + }); + }); + + test("filters to specific flow folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_flow_test"); + + // Create flows in different folders + await createLocalFlow(tempDir, "f/included", "flow_a"); + await createLocalFlow(tempDir, "f/excluded", "flow_b"); + + // Run with folder argument (flow folder path - uses .flow suffix by default) + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/flow_a.flow"], + tempDir, + "folder_flow_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("flow_a"); + expect(output).not.toContain("flow_b"); + }); + }); + + test("filters to specific app folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_app_test"); + + // Create apps in different folders + await createLocalApp(tempDir, "f/included", "app_a"); + await createLocalApp(tempDir, "f/excluded", "app_b"); + + // Run with folder argument (app folder path - uses .app suffix by default) + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/app_a.app"], + tempDir, + "folder_app_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("app_a"); + expect(output).not.toContain("app_b"); + }); + }); + + test("shows up-to-date when folder has no stale items", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_uptodate_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // First run to generate metadata + await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "folder_uptodate_test" + ); + + // Second run with folder - should be up-to-date + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/test/my_script.ts"], + tempDir, + "folder_uptodate_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); + + test("trailing slash is stripped (matches deprecated behavior)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "trailing_slash_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // Run with trailing slash + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/test/my_script.ts/"], + tempDir, + "trailing_slash_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("my_script"); + }); + }); + + test("parent folder matches all children", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "parent_folder_test"); + + // Create scripts in nested folders + await createLocalScript(tempDir, "f/parent", "script_a"); + await createLocalScript(tempDir, "f/parent/child", "script_b"); + await createLocalScript(tempDir, "f/other", "script_c"); + + // Run with parent folder - should match both scripts in f/parent tree + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/parent"], + tempDir, + "parent_folder_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).toContain("script_b"); + expect(output).not.toContain("script_c"); + }); + }); + + test("non-existent folder shows up-to-date", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "nonexistent_folder_test"); + + await createLocalScript(tempDir, "f/exists", "my_script"); + + // Run with non-existent folder + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/does_not_exist"], + tempDir, + "nonexistent_folder_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); +}); From 2e430c4c0b8540df7b6997434a7a9f9134858026 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 13 Mar 2026 04:21:15 -0400 Subject: [PATCH 22/76] feat: add GitHub Enterprise Server (GHES) support for GitHub App git sync (#8344) * feat: add GitHub Enterprise Server (GHES) support for GitHub App git sync Add a self-managed GitHub App mode alongside the existing managed (stats.windmill.dev) mode, enabling git sync for GitHub Enterprise Server and custom GitHub App installations. Backend: - Parameterize GitHub API URLs (no more hardcoded github.com) - Add GITHUB_ENTERPRISE_APP_SETTING global setting - Add OpenAPI specs for ghes_installation_callback and ghes_config endpoints Frontend: - Add instance settings UI for configuring self-managed GitHub Apps with setup instructions and validation - GHES installation flow in gh_success page - Dynamic installation URL based on GHES config - Increase git sync test connection timeout to 10s - Block "Review changes" save when settings are invalid EE companion PR: windmill-labs/windmill-ee-private# Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to c74c86b78a66b976fd9968b21f77903723e668ec This commit updates the EE repository reference after PR #459 was merged in windmill-ee-private. Previous ee-repo-ref: 45e4550110799525b5502cf072c8af8132492638 New ee-repo-ref: c74c86b78a66b976fd9968b21f77903723e668ec Automated by sync-ee-ref workflow. * sqlx --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...096bd0777da52c05e23262572089e0c3c6c46.json | 16 ++ ...b5eea1780226698e419ac59ba55012296913d.json | 28 +++ ...d531b05eaa17fccc599306eb1a96a65ee761.json} | 10 +- ...e98369e1d93514e1618723a34bf96080d4ebc.json | 16 -- ...95c7cda0cba0b119d4fdb8a0dea1b28a1d560.json | 22 --- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi.yaml | 52 ++++++ backend/windmill-common/src/git_sync_oss.rs | 11 +- .../windmill-common/src/global_settings.rs | 1 + .../src/lib/components/InstanceSetting.svelte | 3 + .../lib/components/InstanceSettings.svelte | 19 +- .../lib/components/SuperadminSettings.svelte | 4 +- .../components/SuperadminSettingsInner.svelte | 4 +- .../git_sync/GitSyncContext.svelte.ts | 4 +- .../src/lib/components/instanceSettings.ts | 31 +++- .../instanceSettings/GhesAppSettings.svelte | 172 ++++++++++++++++++ frontend/src/lib/githubApp.ts | 20 +- frontend/src/routes/gh_success/+page.svelte | 70 ++++++- 18 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json create mode 100644 backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json rename backend/.sqlx/{query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json => query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json} (55%) delete mode 100644 backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json delete mode 100644 backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json create mode 100644 frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte diff --git a/backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json b/backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json new file mode 100644 index 0000000000..18a4671616 --- /dev/null +++ b/backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE workspace_settings\n SET git_app_installations = (\n SELECT jsonb_agg(\n CASE\n WHEN (elem->>'installation_id')::bigint = $2 THEN $1::jsonb\n ELSE elem\n END\n )\n FROM jsonb_array_elements(git_app_installations) AS elem\n )\n WHERE workspace_id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46" +} diff --git a/backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json b/backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json new file mode 100644 index 0000000000..33ed0b518b --- /dev/null +++ b/backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'github_base_url' as github_base_url\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "installation_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "github_base_url", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d" +} diff --git a/backend/.sqlx/query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json b/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json similarity index 55% rename from backend/.sqlx/query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json rename to backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json index ac882aec64..4bb689c519 100644 --- a/backend/.sqlx/query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json +++ b/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", + "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "account_id", "type_info": "Text" + }, + { + "ordinal": 2, + "name": "github_base_url", + "type_info": "Text" } ], "parameters": { @@ -20,9 +25,10 @@ ] }, "nullable": [ + null, null, null ] }, - "hash": "0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8" + "hash": "ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761" } diff --git a/backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json b/backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json deleted file mode 100644 index 0efca7e867..0000000000 --- a/backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET git_app_installations = (\n SELECT jsonb_agg(\n CASE\n WHEN (elem->>'installation_id')::bigint = $2 THEN $1::jsonb\n ELSE elem\n END\n )\n FROM jsonb_array_elements(git_app_installations) AS elem\n )\n WHERE workspace_id = $3\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Jsonb", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc" -} diff --git a/backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json b/backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json deleted file mode 100644 index 182fa4e788..0000000000 --- a/backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "installation_id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ee34a47736..87f42ac45b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -344e2aa60d8cafe08b8c57445d2f9555bad7625a +c74c86b78a66b976fd9968b21f77903723e668ec diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3eb3f7f2cf..ad494ee0e1 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1939,6 +1939,58 @@ paths: "200": description: Successfully imported the installation + /w/{workspace}/github_app/ghes_installation_callback: + post: + summary: GHES installation callback + description: Register a self-managed GitHub App installation from GitHub Enterprise Server + operationId: ghesInstallationCallback + tags: + - Git Sync + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - installation_id + properties: + installation_id: + type: integer + format: int64 + description: The GitHub App installation ID from GHES + responses: + "200": + description: GHES installation registered successfully + + /github_app/ghes_config: + get: + summary: Get GHES app config + description: Returns the GitHub Enterprise Server app configuration (without private key) for constructing the installation URL + operationId: getGhesConfig + tags: + - Git Sync + responses: + "200": + description: GHES app configuration + content: + application/json: + schema: + type: object + properties: + base_url: + type: string + app_slug: + type: string + client_id: + type: string + required: + - base_url + - app_slug + - client_id + /users/accept_invite: post: summary: accept invite to workspace diff --git a/backend/windmill-common/src/git_sync_oss.rs b/backend/windmill-common/src/git_sync_oss.rs index a350b74156..4cd7a7c3d1 100644 --- a/backend/windmill-common/src/git_sync_oss.rs +++ b/backend/windmill-common/src/git_sync_oss.rs @@ -21,15 +21,14 @@ pub fn prepend_token_to_github_url( ) -> crate::error::Result { let url = Url::parse(github_url)?; - if url.host_str() != Some("github.com") { - return Err(crate::error::Error::BadRequest( - "Invalid: not a github URL".to_string(), - )); - } + let host = url.host_str().ok_or_else(|| { + crate::error::Error::BadRequest("Invalid GitHub URL: no host".to_string()) + })?; Ok(format!( - "https://x-access-token:{}@github.com{}", + "https://x-access-token:{}@{}{}", installation_token, + host, url.path() )) } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 6d2f225479..1d2aeafce8 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -58,6 +58,7 @@ pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy"; pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route"; pub const SECRET_BACKEND_SETTING: &str = "secret_backend"; pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version"; +pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; pub const ENV_SETTINGS: &[&str] = &[ "DISABLE_NSJAIL", diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 663bb8aa75..1c69ac8a80 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -22,6 +22,7 @@ import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte' import SmtpSettings from './instanceSettings/SmtpSettings.svelte' import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte' + import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte' import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte' import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte' import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte' @@ -716,6 +717,8 @@ {:else if setting.fieldType == 'secret_backend'} + {:else if setting.fieldType == 'github_enterprise_app'} + {/if} {#if hasError} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index b9d355f323..5f5ac925f4 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -30,6 +30,7 @@ quickSetup?: boolean yamlMode?: boolean hasUnsavedChanges?: boolean + hasAnyInvalid?: boolean } let { @@ -40,7 +41,8 @@ onNavigateToTab, quickSetup = false, yamlMode = $bindable(false), - hasUnsavedChanges = $bindable(false) + hasUnsavedChanges = $bindable(false), + hasAnyInvalid = $bindable(false) }: Props = $props() let values: Writable> = writable({}) @@ -77,7 +79,8 @@ smtp_settings: {}, otel: {}, indexer_settings: {}, - critical_error_channels: [] + critical_error_channels: [], + github_enterprise_app: {} } function applyFormDefaults(vals: Record): void { @@ -438,6 +441,10 @@ return result }) + $effect(() => { + hasAnyInvalid = Object.values(invalidCategories).some(Boolean) + }) + export function isDirty(category: string): boolean { return dirtyCategories[category] ?? false } @@ -601,7 +608,8 @@ secret_backend: ['token'], object_store_cache_config: ['secret_key', 'serviceAccountKey'], custom_instance_pg_databases: ['user_pwd'], - rsa_keys: ['private_key'] + rsa_keys: ['private_key'], + github_enterprise_app: ['private_key'] } /** Returns SENSITIVE_UNCHANGED if the value is non-empty and matches the initial */ @@ -1008,6 +1016,11 @@ description="Configure where secrets (secret variables) are stored." link="https://www.windmill.dev/docs/core_concepts/workspace_secret_encryption" /> + {:else if category == 'GitHub Enterprise App'} + {:else if category == 'Auth/OAuth/SAML'} @@ -159,7 +161,7 @@ options={{ right: 'Unified' }} size="xs" /> - + {/snippet}
diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 1f33e768d8..37fca0037b 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -46,7 +46,8 @@ closeDrawer, showHeaderInfo = true, yamlMode = $bindable(false), - hasUnsavedChanges = $bindable(false) + hasUnsavedChanges = $bindable(false), + hasAnyInvalid = $bindable(false) } = $props() function removeHash() { @@ -510,6 +511,7 @@ hideTabs bind:yamlMode bind:hasUnsavedChanges + bind:hasAnyInvalid tab={instanceSettingsCategory} {authSubTab} {closeDrawer} diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index 1940296534..834982c3f3 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -564,8 +564,8 @@ export function createGitSyncContext(workspace: string) { // Use JobManager for polling await jobManager.runWithProgress(() => Promise.resolve(jobId), { workspace, - timeout: 5000, - timeoutMessage: 'Git sync test job timed out after 5s', + timeout: 10000, + timeoutMessage: 'Git sync test job timed out after 10s', onProgress: (status) => { gitSyncTestJobs[idx].status = status.status === 'success' diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 0c0fd51578..450555680c 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -51,6 +51,7 @@ export interface Setting { | 'otel' | 'otel_tracing_proxy' | 'secret_backend' + | 'github_enterprise_app' storage: SettingStorage advancedToggle?: { label: string @@ -665,6 +666,23 @@ export const settings: Record = { storage: 'setting', ee_only: 'HashiCorp Vault integration is an Enterprise Edition feature' } + ], + 'GitHub Enterprise App': [ + { + label: 'GitHub Enterprise App', + description: + 'Configure a self-managed GitHub App for GitHub Enterprise Server (or any GitHub instance) to enable git sync without stats.windmill.dev.', + key: 'github_enterprise_app', + fieldType: 'github_enterprise_app', + storage: 'setting', + ee_only: '', + error: + 'When self-managed mode is enabled, Base URL, App ID, App Slug, and Private Key are required.', + isValid: (v: any) => { + if (!v?.self_managed) return true + return !!(v?.base_url && v?.app_id && v?.app_slug && v?.private_key) + } + } ] } @@ -772,6 +790,13 @@ export const instanceSettingsNavigationGroups = [ { title: 'Advanced', items: [ + { + id: 'github_enterprise_app', + label: 'GitHub Enterprise App', + aiId: 'instance-settings-github-enterprise-app', + aiDescription: 'Self-managed GitHub App for GitHub Enterprise Server git sync', + isEE: true + }, { id: 'private_hub', label: 'Private Hub', @@ -809,7 +834,8 @@ export const tabToCategoryMap: Record = { secret_storage: 'Secret Storage', object_storage: 'Object Storage', jobs: 'Jobs', - private_hub: 'Private Hub' + private_hub: 'Private Hub', + github_enterprise_app: 'GitHub Enterprise App' } export const tabToAuthSubTab: Record = { @@ -838,7 +864,8 @@ export const categoryToTabMap: Record = { 'Secret Storage': 'secret_storage', 'Object Storage': 'object_storage', Jobs: 'jobs', - 'Private Hub': 'private_hub' + 'Private Hub': 'private_hub', + 'GitHub Enterprise App': 'github_enterprise_app' } export interface SearchableSettingItem { diff --git a/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte new file mode 100644 index 0000000000..6ded7d82f2 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte @@ -0,0 +1,172 @@ + + +
+ { + $values['github_enterprise_app'] = { + ...$values['github_enterprise_app'], + self_managed: !selfManaged + } + }} + /> + + {#if !selfManaged} +

+ Using the managed Windmill GitHub App via stats.windmill.dev. Enable self-managed mode to + configure your own GitHub App (required for GitHub Enterprise Server). +

+ {:else} +
+ How to create a GitHub App +
+

+ 1. On your GitHub instance, go to + Settings → Developer settings → GitHub Apps → New GitHub App. +

+

2. Fill in the required fields:

+
    +
  • + GitHub App name: e.g. windmill-sync (this becomes the app + slug) +
  • +
  • + Homepage URL: your Windmill instance URL +
  • +
  • + Callback URL: <your-windmill-url>/gh_success +
  • +
  • + Setup URL (optional): + <your-windmill-url>/gh_success with "Redirect on update" checked +
  • +
  • Uncheck Active under Webhook (not needed)
  • +
+

3. Set repository permissions:

+
    +
  • Contents: Read & write
  • +
  • Metadata: Read-only
  • +
+

+ 4. Under "Where can this GitHub App be installed?", choose + Any account (or restrict to your organization). +

+

+ 5. Click Create GitHub App. On the next page, note the + App ID and Client ID. +

+

+ 6. Scroll down and click Generate a private key. Save the + downloaded .pem file — paste its contents into the Private Key field below. +

+

+ 7. The App Slug is the URL-friendly name shown in the + app's URL (e.g. github.com/apps/windmill-sync). +

+

+ 8. The Base URL is your GitHub instance root (e.g. + https://github.com or https://github.mycompany.com). +

+
+
+ {/if} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
diff --git a/frontend/src/lib/githubApp.ts b/frontend/src/lib/githubApp.ts index 74ce0438ed..48c2cd1b56 100644 --- a/frontend/src/lib/githubApp.ts +++ b/frontend/src/lib/githubApp.ts @@ -1,4 +1,8 @@ -import { GitSyncService, type GetGlobalConnectedRepositoriesResponse } from '$lib/gen' +import { + GitSyncService, + type GetGlobalConnectedRepositoriesResponse, + type GetGhesConfigResponse +} from '$lib/gen' import { sendUserToast } from '$lib/toast' import { base } from '$lib/base' @@ -129,7 +133,19 @@ export async function loadGithubInstallations( }) ) - state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` + // Check if GHES app is configured; if so, use GHES installation URL + try { + const ghesConfig: GetGhesConfigResponse = await GitSyncService.getGhesConfig() + if (ghesConfig?.base_url && ghesConfig?.app_slug) { + const ghesBaseUrl = ghesConfig.base_url.replace(/\/$/, '') + state.githubInstallationUrl = `${ghesBaseUrl}/apps/${ghesConfig.app_slug}/installations/new?state=${stateParam}` + } else { + state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` + } + } catch { + // No GHES config — use default github.com URL + state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` + } } catch (err) { const githubError = handleGitHubAppError(err, 'load installations') sendUserToast(`Failed to load GitHub installations: ${githubError.message}`, true) diff --git a/frontend/src/routes/gh_success/+page.svelte b/frontend/src/routes/gh_success/+page.svelte index 3886857126..eab7502757 100644 --- a/frontend/src/routes/gh_success/+page.svelte +++ b/frontend/src/routes/gh_success/+page.svelte @@ -13,6 +13,74 @@ onMount(async () => { const url = new URL(window.location.href) + + // Check for GHES flow: GitHub redirects back with installation_id and state params + // (no jwt_token param — that's the managed flow via stats.windmill.dev) + const jwt_token = url.searchParams.get('jwt_token') || '' + const stateParam = url.searchParams.get('state') || '' + + if (!jwt_token && stateParam) { + // GHES self-managed flow + await handleGhesFlow(url, stateParam) + } else { + // Managed flow (existing) + await handleManagedFlow(url) + } + }) + + async function handleGhesFlow(url: URL, stateParam: string) { + const installation_id_str = url.searchParams.get('installation_id') || '' + const installation_id = parseInt(installation_id_str, 10) + + let workspace_id: string + try { + const state = JSON.parse(decodeURIComponent(stateParam)) + workspace_id = state.workspace_id + } catch { + isLoading = false + errorMessage = 'Invalid state parameter' + sendUserToast('Invalid state parameter in the URL', true) + return + } + + if (!workspace_id || isNaN(installation_id)) { + isLoading = false + errorMessage = 'Missing or invalid required parameters' + sendUserToast('Missing or invalid required parameters in the URL', true) + return + } + + try { + const response = await fetch( + `/api/w/${workspace_id}/github_app/ghes_installation_callback`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + installation_id + }) + } + ) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || 'Failed to complete GitHub Enterprise app installation') + } + + isSuccess = true + sendUserToast('GitHub Enterprise app installed successfully', false) + } catch (error) { + console.error('Error during GitHub Enterprise app installation:', error) + errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + sendUserToast(`Error installing GitHub Enterprise app: ${errorMessage}`, true) + } finally { + isLoading = false + } + } + + async function handleManagedFlow(url: URL) { const workspace_id = url.searchParams.get('workspace_id') || '' const installation_id_str = url.searchParams.get('installation_id') || '' const account_id = url.searchParams.get('account_id') || '' @@ -54,7 +122,7 @@ } finally { isLoading = false } - }) + }
From 2d5b72b3cea7430a5dd239aff59da17be76fad45 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 13 Mar 2026 08:50:19 +0000 Subject: [PATCH 23/76] chore: update vite/vitest to stable v8/v4 and remove legacy-peer-deps (#8349) Co-authored-by: Claude Opus 4.5 --- frontend/.npmrc | 2 +- frontend/package-lock.json | 1552 ++++++++++++++++++++++++++++++------ frontend/package.json | 4 +- 3 files changed, 1298 insertions(+), 260 deletions(-) diff --git a/frontend/.npmrc b/frontend/.npmrc index 521a9f7c07..8b13789179 100644 --- a/frontend/.npmrc +++ b/frontend/.npmrc @@ -1 +1 @@ -legacy-peer-deps=true + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cae11fc06e..6de3434311 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -147,9 +147,9 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0-beta.16", + "vite": "^8.0.0", "vite-plugin-mkcert": "^1.17.5", - "vitest": "^4.1.0-beta.5", + "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" }, "optionalDependencies": { @@ -256,6 +256,33 @@ "tslib": "^2.3.1" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -736,23 +763,91 @@ "@codingame/monaco-vscode-view-title-bar-service-override": "25.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "node_modules/@csstools/css-parser-algorithms": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", + "integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^2.4.1" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", + "integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz", + "integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "dev": true, + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "license": "MIT", "optional": true, "dependencies": { @@ -760,10 +855,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "license": "MIT", "optional": true, "dependencies": { @@ -1098,7 +1192,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1109,7 +1202,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1120,7 +1212,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1130,14 +1221,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1258,7 +1347,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1326,7 +1414,7 @@ "version": "0.115.0", "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1336,7 +1424,7 @@ "version": "0.115.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -1389,7 +1477,7 @@ "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@popperjs/core": { @@ -1409,13 +1497,12 @@ "license": "SEE LICENSE IN LICENSE" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.6.tgz", - "integrity": "sha512-kvjTSWGcrv+BaR2vge57rsKiYdVR8V8CoS0vgKrc570qRBfty4bT+1X0z3j2TaVV+kAYzA0PjeB9+mdZyqUZlg==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1426,13 +1513,12 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.6.tgz", - "integrity": "sha512-+tJhD21KvGNtUrpLXrZQlT+j5HZKiEwR2qtcZb3vNOUpvoT9QjEykr75ZW/Kr0W89gose/HVXU6351uVZD8Qvw==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1443,13 +1529,12 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.6.tgz", - "integrity": "sha512-DKNhjMk38FAWaHwUt1dFR3rA/qRAvn2NUvSG2UGvxvlMxSmN/qqww/j4ABAbXhNRXtGQNmrAINMXRuwHl16ZHg==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1460,13 +1545,12 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.6.tgz", - "integrity": "sha512-8TThsRkCPAnfyMBShxrGdtoOE6h36QepqRQI97iFaQSCRbHFWHcDHppcojZnzXoruuhPnjMEygzaykvPVJsMRg==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1477,13 +1561,12 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.6.tgz", - "integrity": "sha512-ZfmFoOwPUZCWtGOVC9/qbQzfc0249FrRUOzV2XabSMUV60Crp211OWLQN1zmQAsRIVWRcEwhJ46Z1mXGo/L/nQ==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1494,13 +1577,12 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.6.tgz", - "integrity": "sha512-ZsGzbNETxPodGlLTYHaCSGVhNN/rvkMDCJYHdT7PZr5jFJRmBfmDi2awhF64Dt2vxrJqY6VeeYSgOzEbHRsb7Q==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1511,13 +1593,44 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.6.tgz", - "integrity": "sha512-elPpdevtCdUOqziemR86C4CSCr/5sUxalzDrf/CJdMT+kZt2C556as++qHikNOz0vuFf52h+GJNXZM08eWgGPQ==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "cpu": [ + "s390x" + ], "license": "MIT", "optional": true, "os": [ @@ -1528,13 +1641,12 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.6.tgz", - "integrity": "sha512-IBwXsf56o3xhzAyaZxdM1CX8UFiBEUFCjiVUgny67Q8vPIqkjzJj0YKhd3TbBHanuxThgBa59f6Pgutg2OGk5A==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1545,13 +1657,12 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.6.tgz", - "integrity": "sha512-vOk7G8V9Zm+8a6PL6JTpCea61q491oYlGtO6CvnsbhNLlKdf0bbCPytFzGQhYmCKZDKkEbmnkcIprTEGCURnwg==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1562,13 +1673,12 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.6.tgz", - "integrity": "sha512-ASjEDI4MRv7XCQb2JVaBzfEYO98JKCGrAgoW6M03fJzH/ilCnC43Mb3ptB9q/lzsaahoJyIBoAGKAYEjUvpyvQ==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1579,13 +1689,12 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.6.tgz", - "integrity": "sha512-mYa1+h2l6Zc0LvmwUh0oXKKYihnw/1WC73vTqw+IgtfEtv47A+rWzzcWwVDkW73+UDr0d/Ie/HRXoaOY22pQDw==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1596,13 +1705,12 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.6.tgz", - "integrity": "sha512-e2ABskbNH3MRUBMjgxaMjYIw11DSwjLJxBII3UgpF6WClGLIh8A20kamc+FKH5vIaFVnYQInmcLYSUVpqMPLow==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1613,13 +1721,12 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.6.tgz", - "integrity": "sha512-dJVc3ifhaRXxIEh1xowLohzFrlQXkJ66LepHm+CmSprTWgVrPa8Fx3OL57xwIqDEH9hufcKkDX2v65rS3NZyRA==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1630,10 +1737,10 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.6.tgz", - "integrity": "sha512-Y0+JT8Mi1mmW08K6HieG315XNRu4L0rkfCpA364HtytjgiqYnMYRdFPcxRl+BQQqNXzecL2S9nii+RUpO93XIA==", - "dev": true, + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "devOptional": true, "license": "MIT" }, "node_modules/@rollup/rollup-linux-x64-gnu": { @@ -1682,7 +1789,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@streamparser/json": { @@ -1704,7 +1811,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1724,7 +1830,7 @@ "version": "2.53.4", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1819,7 +1925,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "deepmerge": "^4.3.1", @@ -1926,7 +2032,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1948,7 +2053,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/d3": { @@ -2256,7 +2361,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/geojson": { @@ -2304,12 +2408,28 @@ "@types/unist": "*" } }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -2321,7 +2441,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/unist": { @@ -2657,16 +2776,16 @@ "license": "ISC" }, "node_modules/@vitest/expect": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0-beta.5.tgz", - "integrity": "sha512-rGZIMfkb+iEjL5+ulpWON5NY1y3bEc+I3btLlayKZ8wvsLhpS+wduc6DaNLWhFWxYwFXmnrLdMd9NRUE9cwySw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0-beta.5", - "@vitest/utils": "4.1.0-beta.5", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, @@ -2675,13 +2794,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0-beta.5.tgz", - "integrity": "sha512-oUE5vFOY7onbFjFGSbWSV6ryDbVmymRUQOQf978k6ZA7EmyRykUISqTiVZVzv/dpPH3IhnctJtHdoglYNG+SPQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0-beta.5", + "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2690,7 +2809,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -2702,9 +2821,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0-beta.5.tgz", - "integrity": "sha512-QH/FGecnl2uwLveL/n1awB/nm/dJL9M0vMKVwmW0tvLAqTOp5GQQOypRuVvpXNFGhIl2bfpUSjruuDQlCBeFjw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "license": "MIT", "dependencies": { @@ -2715,13 +2834,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0-beta.5.tgz", - "integrity": "sha512-9OP3INBsI9NhX0+n5syXtZOHooSFl+ctkDVbav6KlCO3CBV2g4TUPllYRAHsKWb6FvkpM2BVO8yO5jgqaZqnBA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0-beta.5", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -2736,14 +2855,14 @@ "license": "MIT" }, "node_modules/@vitest/snapshot": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0-beta.5.tgz", - "integrity": "sha512-ny6wFeFmA700AzTuB5qMSTKsLXtCz8m7CI2ESlcuGMrI7d9kdVUVD6ziRt0Hp2M3C3jIWvd5C6f4ZczAEFoytQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0-beta.5", - "@vitest/utils": "4.1.0-beta.5", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2759,9 +2878,9 @@ "license": "MIT" }, "node_modules/@vitest/spy": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0-beta.5.tgz", - "integrity": "sha512-eeiIMRR/xBXJxzGhbims+4UOim2bVXzSNf9bLRi0iHWWnXXaK/DS0pYpkLVO83EcqUWbBlqMx9R8Y6tLF6aCJQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", "funding": { @@ -2769,13 +2888,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0-beta.5.tgz", - "integrity": "sha512-yDobPgmVL/4YhVXsbBcmeUb5CIdZiJkoonPnuJXKOxmnj0XZyu7OgIX3KLOcRStbia3nJZ9VIIBWoSv+HS+wVA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0-beta.5", + "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, @@ -2813,6 +2932,13 @@ "@xterm/xterm": "^5.0.0" } }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT", + "peer": true + }, "node_modules/@xyflow/svelte": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.3.1.tgz", @@ -2887,7 +3013,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3066,7 +3191,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -3082,6 +3206,17 @@ "node": ">=8" } }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3099,6 +3234,17 @@ "node": ">=12" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -3167,7 +3313,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -3183,6 +3328,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", + "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3499,6 +3652,68 @@ "node": ">= 6" } }, + "node_modules/camelcase-keys": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", + "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "camelcase": "^6.3.0", + "map-obj": "^4.1.0", + "quick-lru": "^5.1.1", + "type-fest": "^1.2.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -3726,7 +3941,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3843,7 +4057,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3862,6 +4076,34 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -3899,6 +4141,17 @@ "postcss": "^8.0.9" } }, + "node_modules/css-functions-list": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -4241,6 +4494,60 @@ } } }, + "node_modules/decamelize": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", + "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -4318,7 +4625,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4420,7 +4727,6 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", - "dev": true, "license": "MIT" }, "node_modules/devlop": { @@ -4716,6 +5022,17 @@ "errno": "cli.js" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5143,7 +5460,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -5340,6 +5656,17 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastpriorityqueue": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/fastpriorityqueue/-/fastpriorityqueue-0.7.2.tgz", @@ -5710,6 +6037,50 @@ "node": "*" } }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -5747,6 +6118,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5816,6 +6195,17 @@ "uglify-js": "^3.1.4" } }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6027,6 +6417,56 @@ "node": ">=12.0.0" } }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -6097,6 +6537,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6107,6 +6558,20 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -6129,8 +6594,8 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC", - "optional": true + "devOptional": true, + "license": "ISC" }, "node_modules/inline-style-parser": { "version": "0.1.1", @@ -6165,6 +6630,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6262,11 +6735,32 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -6346,7 +6840,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -6358,6 +6852,14 @@ "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", "license": "BSD-3-Clause" }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -6387,6 +6889,14 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/json-refs": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/json-refs/-/json-refs-3.0.15.tgz", @@ -6546,11 +7056,22 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -6782,10 +7303,10 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", - "dev": true, + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -6798,27 +7319,26 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6833,13 +7353,12 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6854,13 +7373,12 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6875,13 +7393,12 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6896,13 +7413,12 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6917,13 +7433,12 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6938,13 +7453,12 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6959,13 +7473,12 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6980,13 +7493,12 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7001,13 +7513,12 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7022,13 +7533,12 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7066,7 +7576,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -7131,6 +7640,14 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -7186,12 +7703,25 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mapbox-to-css-font": { "version": "2.4.5", "resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-2.4.5.tgz", @@ -7229,6 +7759,18 @@ "node": ">= 0.4" } }, + "node_modules/mathml-tag-names": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", + "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -7458,6 +8000,48 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/meow": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/meow/-/meow-10.1.5.tgz", + "integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/minimist": "^1.2.2", + "camelcase-keys": "^7.0.0", + "decamelize": "^5.0.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.2", + "read-pkg-up": "^8.0.0", + "redent": "^4.0.0", + "trim-newlines": "^4.0.2", + "type-fest": "^1.2.2", + "yargs-parser": "^20.2.9" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8138,6 +8722,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -8267,7 +8867,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -8424,6 +9024,23 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8547,7 +9164,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -8753,6 +9370,26 @@ "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", "license": "MIT" }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -8908,7 +9545,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { @@ -9020,10 +9657,10 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "devOptional": true, "funding": [ { "type": "opencollective", @@ -9611,6 +10248,14 @@ "postcss": "^8.4.31" } }, + "node_modules/postcss-resolve-nested-selector": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", + "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/postcss-safe-parser": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", @@ -9727,7 +10372,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -10089,6 +10734,73 @@ "pify": "^2.3.0" } }, + "node_modules/read-pkg": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", + "integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^3.0.2", + "parse-json": "^5.2.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-8.0.0.tgz", + "integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "find-up": "^5.0.0", + "read-pkg": "^6.0.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -10118,6 +10830,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", + "integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "indent-string": "^5.0.0", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -10324,14 +11054,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.6.tgz", - "integrity": "sha512-B8vFPV1ADyegoYfhg+E7RAucYKv0xdVlwYYsIJgfPNeiSxZGWNxts9RqhyGzC11ULK/VaeXyKezGCwpMiH8Ktw==", - "dev": true, + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.6" + "@rolldown/pluginutils": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" @@ -10340,19 +11070,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.6", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.6", - "@rolldown/binding-darwin-x64": "1.0.0-rc.6", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.6", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.6", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.6", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.6", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.6", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.6", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.6", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.6", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.6", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.6" + "@rolldown/binding-android-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" } }, "node_modules/run-parallel": { @@ -10469,7 +11201,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/set-function-length": { @@ -10680,7 +11412,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -10701,6 +11433,25 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/sort-asc": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz", @@ -10743,7 +11494,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10759,6 +11510,46 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0", + "peer": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0", + "peer": true + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -10774,9 +11565,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -10845,6 +11636,20 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -10858,6 +11663,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-search": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", + "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/style-to-object": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", @@ -10899,6 +11712,66 @@ "node": ">=4" } }, + "node_modules/stylelint": { + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-15.11.0.tgz", + "integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/media-query-list-parser": "^2.1.4", + "@csstools/selector-specificity": "^3.0.0", + "balanced-match": "^2.0.0", + "colord": "^2.9.3", + "cosmiconfig": "^8.2.0", + "css-functions-list": "^3.2.1", + "css-tree": "^2.3.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.1", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^7.0.0", + "global-modules": "^2.0.0", + "globby": "^11.1.0", + "globjoin": "^0.1.4", + "html-tags": "^3.3.1", + "ignore": "^5.2.4", + "import-lazy": "^4.0.0", + "imurmurhash": "^0.1.4", + "is-plain-object": "^5.0.0", + "known-css-properties": "^0.29.0", + "mathml-tag-names": "^2.1.3", + "meow": "^10.1.5", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.28", + "postcss-resolve-nested-selector": "^0.1.1", + "postcss-safe-parser": "^6.0.0", + "postcss-selector-parser": "^6.0.13", + "postcss-value-parser": "^4.2.0", + "resolve-from": "^5.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "style-search": "^0.1.0", + "supports-hyperlinks": "^3.0.0", + "svg-tags": "^1.0.0", + "table": "^6.8.1", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + } + }, "node_modules/stylelint-config-recommended": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-13.0.0.tgz", @@ -10912,6 +11785,78 @@ "stylelint": "^15.10.0" } }, + "node_modules/stylelint/node_modules/@csstools/selector-specificity": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.1.tgz", + "integrity": "sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.13" + } + }, + "node_modules/stylelint/node_modules/file-entry-cache": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-7.0.2.tgz", + "integrity": "sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flat-cache": "^3.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stylelint/node_modules/known-css-properties": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz", + "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/stylelint/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/stylelint/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -11036,6 +11981,24 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -11053,7 +12016,6 @@ "version": "5.53.5", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.5.tgz", "integrity": "sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -11377,6 +12339,13 @@ "typescript": "^4.9.4 || ^5.0.0" } }, + "node_modules/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true, + "peer": true + }, "node_modules/svgo": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", @@ -11420,6 +12389,24 @@ "dev": true, "license": "MIT" }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/tailwind-merge": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", @@ -11651,7 +12638,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -11668,7 +12655,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -11686,7 +12673,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -11696,9 +12683,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -11722,7 +12709,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -11744,6 +12731,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/trim-newlines": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", + "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -11843,7 +12844,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12052,6 +13053,18 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -12095,17 +13108,17 @@ } }, "node_modules/vite": { - "version": "8.0.0-beta.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0-beta.16.tgz", - "integrity": "sha512-c0t7hYkxsjws89HH+BUFh/sL3BpPNhNsL9CJrTpMxBmwKQBRSa5OJ5w4o9O0bQVI/H/vx7UpUUIevvXa37NS/Q==", - "dev": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/runtime": "0.115.0", - "lightningcss": "^1.31.1", + "lightningcss": "^1.32.0", "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rolldown": "1.0.0-rc.6", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "bin": { @@ -12195,7 +13208,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -12208,7 +13221,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", - "dev": true, + "devOptional": true, "license": "MIT", "workspaces": [ "tests/deps/*", @@ -12225,26 +13238,26 @@ } }, "node_modules/vitest": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0-beta.5.tgz", - "integrity": "sha512-oFoeAOQednbyV7mR1hAmT4/yQ4xnNzvHUcU0lFwxo8riim0wsuh2EXF/xOtsT3q33ACeufP5BkWFVyJUC5B/DQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0-beta.5", - "@vitest/mocker": "4.1.0-beta.5", - "@vitest/pretty-format": "4.1.0-beta.5", - "@vitest/runner": "4.1.0-beta.5", - "@vitest/snapshot": "4.1.0-beta.5", - "@vitest/spy": "4.1.0-beta.5", - "@vitest/utils": "4.1.0-beta.5", + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", @@ -12265,10 +13278,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0-beta.5", - "@vitest/browser-preview": "4.1.0-beta.5", - "@vitest/browser-webdriverio": "4.1.0-beta.5", - "@vitest/ui": "4.1.0-beta.5", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" @@ -12779,6 +13792,21 @@ "devOptional": true, "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/xml-utils": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", @@ -12960,6 +13988,17 @@ "node": ">=12" } }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yargs/node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -13003,7 +14042,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" }, "node_modules/zod": { diff --git a/frontend/package.json b/frontend/package.json index 1412bceecc..d34ea12a90 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -70,9 +70,9 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0-beta.16", + "vite": "^8.0.0", "vite-plugin-mkcert": "^1.17.5", - "vitest": "^4.1.0-beta.5", + "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" }, "overrides": { From 96229575e6641b9c294cf45ed0d9c530a94ffca9 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 13 Mar 2026 13:07:49 +0100 Subject: [PATCH 24/76] =?UTF-8?q?chore:=20dev=20tooling=20=E2=80=94=20wm-t?= =?UTF-8?q?s-nav=20navigator,=20format=20hooks,=20review=20skill=20(#8337)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: remove wm-cursor, add local-review skill, update PR skill for EE - Remove the unused wm-cursor script and all references to it in README_WORKMUX_DEV.md and worktree-common.sh - Add /local-review skill for code review (bugs + CLAUDE.md compliance) - Add EE companion PR workflow to the /pr skill Co-Authored-By: Claude Opus 4.6 * chore: add wm-ts-nav tree-sitter navigator and fix format hooks - Add wm-ts-nav: standalone tree-sitter code navigator with SQLite index for fast symbol search, definition lookup, and file outlines across Rust, TypeScript, and Svelte files (~12ms warm, ~1s cold for 482 files) - Fix format hooks: surface errors instead of swallowing with 2>/dev/null, use direct prettier path with svelte plugin, add success feedback - Add wm-ts-nav commands to settings allow list - Document wm-ts-nav usage in CLAUDE.md Co-Authored-By: Claude Opus 4.6 * feat(wm-ts-nav): add refs command and --parent filter - refs: find usages of a symbol in code, skipping comments and strings (tree-sitter AST walk, ~46ms for 482 files vs grep's 4ms but no noise) - --parent filter on search: find all methods on a type across all files (e.g. search "%" --kind function --parent ServiceName) - Update CLAUDE.md with clearer when-to-use guidance Co-Authored-By: Claude Opus 4.6 * feat(wm-ts-nav): index refs in DB with import-path resolution Co-Authored-By: Claude Opus 4.5 * feat(wm-ts-nav): add body, callers, callees commands and refs --file/--caller - body: extract a symbol's source code from disk using indexed line ranges - callers: cross-file call graph via SQL join of refs + symbols tables - callees: list all identifiers referenced within a symbol's body - refs --file: scope results to files matching a substring - refs --caller: annotate each ref with the containing function name Co-Authored-By: Claude Opus 4.6 * feat(wm-ts-nav): add auto-rebuilding wrapper script The `wm-ts-nav/nav` wrapper checks if source files are newer than the binary and rebuilds automatically. Invoked via `sh wm-ts-nav/nav` to avoid needing executable permissions after clone. Co-Authored-By: Claude Opus 4.6 * docs: tighten CLAUDE.md nav section for actionable guidance Remove redundant question→command mapping, latency numbers, and excessive examples. Lead with "prefer wm-ts-nav over Read to save context window" and keep only the patterns that change behavior. Co-Authored-By: Claude Opus 4.6 * chore: revert backend/Cargo.lock to main Co-Authored-By: Claude Opus 4.6 * chore: promote wm-ts-nav in workflow, copy binary to worktrees - CLAUDE.md: integrate wm-ts-nav into Workflow step 1 and Core Principles so agents use outline/body before full file reads - workmux: copy built binary via files.copy - worktree-common.sh: copy binary in wm_copy_dependencies for webmux Co-Authored-By: Claude Opus 4.6 * fix(wm-ts-nav): fix double indexing, add TSX grammar, remove needless clone - Reuse index stats from the pre-query update instead of indexing twice on the Index command - Add Lang::Tsx variant so .tsx/.jsx files use LANGUAGE_TSX instead of LANGUAGE_TYPESCRIPT (Svelte stays on TS since script blocks are pure TS) - Remove source.clone() for non-Svelte files — move directly instead Co-Authored-By: Claude Opus 4.6 * fix(wm-ts-nav): fix svelte line numbers, add class methods, innermost caller Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.6 --- .claude/hooks/format-backend.sh | 6 +- .claude/hooks/format-frontend.sh | 6 +- .claude/settings.json | 6 + .claude/skills/local-review/SKILL.md | 98 ++++ .claude/skills/pr/SKILL.md | 23 + .workmux.yaml | 1 + CLAUDE.md | 38 +- README_WORKMUX_DEV.md | 78 --- docs/enterprise.md | 15 +- scripts/wm-cursor | 401 --------------- scripts/worktree-common.sh | 8 +- wm-ts-nav/Cargo.lock | 620 +++++++++++++++++++++++ wm-ts-nav/Cargo.toml | 21 + wm-ts-nav/nav | 10 + wm-ts-nav/src/db.rs | 422 ++++++++++++++++ wm-ts-nav/src/indexer.rs | 102 ++++ wm-ts-nav/src/main.rs | 270 ++++++++++ wm-ts-nav/src/parser.rs | 716 +++++++++++++++++++++++++++ 18 files changed, 2351 insertions(+), 490 deletions(-) create mode 100644 .claude/skills/local-review/SKILL.md delete mode 100755 scripts/wm-cursor create mode 100644 wm-ts-nav/Cargo.lock create mode 100644 wm-ts-nav/Cargo.toml create mode 100755 wm-ts-nav/nav create mode 100644 wm-ts-nav/src/db.rs create mode 100644 wm-ts-nav/src/indexer.rs create mode 100644 wm-ts-nav/src/main.rs create mode 100644 wm-ts-nav/src/parser.rs diff --git a/.claude/hooks/format-backend.sh b/.claude/hooks/format-backend.sh index d6077d7482..2b77f71432 100755 --- a/.claude/hooks/format-backend.sh +++ b/.claude/hooks/format-backend.sh @@ -13,8 +13,10 @@ fi # Check if the file is in the backend directory and is a Rust file if [[ "$FILE_PATH" == *"/backend/"* ]] && [[ "$FILE_PATH" =~ \.rs$ ]]; then cd "$CLAUDE_PROJECT_DIR/backend" || exit 0 - # Run rustfmt with config from rustfmt.toml (edition=2021) - rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>/dev/null || true + # Run rustfmt, surface errors as context but don't block Claude + if rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>&1; then + echo "Formatted $(basename "$FILE_PATH")" + fi fi exit 0 diff --git a/.claude/hooks/format-frontend.sh b/.claude/hooks/format-frontend.sh index d0b4f0559b..37c3f8d4ec 100755 --- a/.claude/hooks/format-frontend.sh +++ b/.claude/hooks/format-frontend.sh @@ -15,8 +15,10 @@ if [[ "$FILE_PATH" == *"/frontend/"* ]]; then # Check if it's a formattable file type if [[ "$FILE_PATH" =~ \.(ts|js|svelte|json|css|html|md)$ ]]; then cd "$CLAUDE_PROJECT_DIR/frontend" || exit 0 - # Run prettier silently, don't fail the hook if prettier fails - npx prettier --write "$FILE_PATH" 2>/dev/null || true + # Run prettier, surface errors as context but don't block Claude + if ./node_modules/.bin/prettier --plugin prettier-plugin-svelte --write "$FILE_PATH" 2>&1; then + echo "Formatted $(basename "$FILE_PATH")" + fi fi fi diff --git a/.claude/settings.json b/.claude/settings.json index cf8bfdd284..0596b17e91 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -28,6 +28,12 @@ "Bash(git show:*)", "Bash(git blame:*)", "Bash(cargo check:*)", + "Bash(cargo build --release:*)", + "Bash(sh wm-ts-nav/nav:*)", + "Bash(wm-ts-nav/nav:*)", + "Bash(./wm-ts-nav/nav:*)", + "Bash(wm-ts-nav/target/release/wm-ts-nav:*)", + "Bash(./wm-ts-nav/target/release/wm-ts-nav:*)", "mcp__ide__getDiagnostics", "Bash(npm run generate-backend-client:*)", "Bash(npm run check:*)", diff --git a/.claude/skills/local-review/SKILL.md b/.claude/skills/local-review/SKILL.md new file mode 100644 index 0000000000..f14f5608db --- /dev/null +++ b/.claude/skills/local-review/SKILL.md @@ -0,0 +1,98 @@ +--- +name: local-review +user_invocable: true +description: Code review a pull request for bugs and CLAUDE.md compliance. MUST use when asked to review code. +--- + +# Local Code Review Skill + +Review a pull request for real bugs and CLAUDE.md compliance violations. This review targets HIGH SIGNAL issues only. + +## Review Philosophy + +- **Only flag issues you are certain about.** If you are not sure an issue is real, do not flag it. False positives erode trust and waste reviewer time. +- Think like a senior engineer doing a final review — flag things that would cause incidents, not things that are merely imperfect. + +## What to Flag + +- Code that won't compile or parse (syntax errors, type errors, missing imports) +- Code that will definitely produce wrong results regardless of inputs +- Clear, unambiguous CLAUDE.md violations (quote the exact rule being violated) +- Security issues in introduced code (injection, auth bypass, data exposure) +- Incorrect logic that will fail in production + +## What NOT to Flag + +- Code style or quality concerns +- Potential issues that depend on specific inputs or runtime state +- Subjective suggestions or improvements +- Pre-existing issues not introduced by this PR +- Pedantic nitpicks a senior engineer wouldn't flag +- Issues a linter or type checker will catch +- General quality concerns unless explicitly prohibited in CLAUDE.md +- Issues silenced via lint ignore comments + +## Execution Steps + +1. **Determine the PR scope**: + - If an argument is provided, use it as the PR number or branch + - Otherwise, detect from the current branch vs main + - Run `gh pr view` if a PR exists, or use `git diff main...HEAD` + +2. **Find relevant CLAUDE.md files**: + - Read the root `CLAUDE.md` + - Check for CLAUDE.md files in directories containing changed files + +3. **Get the diff and metadata**: + - `gh pr diff` or `git diff main...HEAD` for the full diff + - `gh pr view` or `git log main..HEAD --oneline` for context + +4. **Read changed files** where the diff alone is insufficient to understand context + +5. **Review for**: + - CLAUDE.md compliance — check each rule against the changed code + - Bugs and logic errors — will this code work correctly? + - Security issues — injection, auth, data exposure in new code + +6. **Self-validate each finding**: Before reporting, ask yourself: + - "Is this definitely a real issue, not a false positive?" + - "Would a senior engineer flag this in review?" + - If the answer to either is no, discard the finding + +7. **Output findings** to the terminal (default) or post as PR comments (with `--comment` flag) + +## Output Format + +``` +## Code review + +Found N issues: + +1. () + + +2. () + +``` + +If no issues are found: + +``` +## Code review + +No issues found. Checked for bugs and CLAUDE.md compliance. +``` + +## Posting Comments (--comment flag) + +If the user passes `--comment`, post findings as inline PR comments using: + +```bash +gh pr review --comment --body "" +``` + +Or for inline comments on specific lines: + +```bash +gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="" -f event="COMMENT" -f comments="[...]" +``` diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md index f0c4e822e4..ab67b58748 100644 --- a/.claude/skills/pr/SKILL.md +++ b/.claude/skills/pr/SKILL.md @@ -33,6 +33,7 @@ Follow conventional commit format for the PR title: - Keep under 70 characters - Use lowercase, imperative mood - No period at the end +- If `*_ee.rs` files were modified, prefix with `[ee]`: `[ee] : ` ## PR Body Format @@ -85,3 +86,25 @@ Generated with [Claude Code](https://claude.com/claude-code) )" ``` 7. Return the PR URL to the user + +## EE Companion PR (when `*_ee.rs` files were modified) + +The `*_ee.rs` files in the windmill repo are **symlinks** to `windmill-ee-private` — changes won't appear in `git diff` of the windmill repo. Instead, check the EE repo for uncommitted or unpushed changes. + +Follow the full EE PR workflow in `docs/enterprise.md`. The key PR-specific details: + +1. Find the EE repo/worktree: see "Finding the EE Repo" in `docs/enterprise.md` +2. Check for changes: `git -C status --short` + - If there are no changes in the EE repo, skip this entire section +3. Follow steps 1–5 from the "EE PR Workflow" in `docs/enterprise.md` +4. Create the companion PR (title does NOT get the `[ee]` prefix): + ```bash + gh pr create --draft --repo windmill-labs/windmill-ee-private --title ": " --body "$(cat <<'EOF' + Companion PR for windmill-labs/windmill# + + --- + Generated with [Claude Code](https://claude.com/claude-code) + EOF + )" + ``` +5. Commit `ee-repo-ref.txt` and push the updated windmill branch diff --git a/.workmux.yaml b/.workmux.yaml index 46049109c0..b36a8f16bf 100644 --- a/.workmux.yaml +++ b/.workmux.yaml @@ -67,6 +67,7 @@ files: copy: - backend/.env - scripts/ + - wm-ts-nav/target/release/wm-ts-nav sandbox: enabled: false diff --git a/CLAUDE.md b/CLAUDE.md index fe22fae0f7..f77dbb0600 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Open-source platform for internal tools, workflows, API integrations, background ## Workflow -1. **Understand**: Before coding, read relevant docs from `docs/` to understand the area you're changing +1. **Understand**: Before coding, use `wm-ts-nav` to explore (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code. Read `docs/` for domain context. 2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages 3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`) 4. **Validate**: After every change, run the appropriate checks per `docs/validation.md` @@ -15,6 +15,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow - **Backend patterns**: use the `rust-backend` skill when writing Rust code - **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill. +- **Code review**: use `/local-review` to review a PR for bugs and CLAUDE.md compliance - **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc` - **Brand/UI guidelines**: `frontend/brand-guidelines.md` @@ -49,8 +50,43 @@ let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props() 2. **Create a `useMyPropState()` helper** — encapsulate the undefined-handling logic in a reusable function and call it higher in the component tree, so the child component always receives a defined value. +## Code Navigation + +`wm-ts-nav` is an AST-aware code navigator. Use **Grep** for regex/pattern search. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries. + +**Prefer wm-ts-nav over Read** to save context window: +- `outline ` instead of reading a full file — understand structure first, then `body` or Read for specifics +- `body "X"` instead of reading a full file to see one function/struct +- `refs "X" --caller` instead of reading files to find which function contains each reference +- `callers "X"` / `callees "X"` for call-graph questions + +```bash +NAV="sh wm-ts-nav/nav" +# Use --root backend for Rust, --root frontend/src for TS/Svelte +$NAV --root backend outline backend/path/to/file.rs # file structure +$NAV --root backend def "ServiceName" # find definition +$NAV --root backend body "decrypt_oauth_data" # extract source code +$NAV --root backend search "%" --parent ServiceName # methods on a type +$NAV --root backend search "Trigger" --kind struct # find by kind +$NAV --root backend refs "X" --file handler.rs --caller # scoped refs with caller +$NAV --root backend callers "X" # who calls X? +$NAV --root backend callees "X" # what does X call? +``` + +**Limitations** — syntax-level analysis, no type inference: +- Import paths are stored literally — `crate::X` and `super::X` pointing to the same type won't be linked +- Re-export chains (`pub use`) aren't followed — refs through different re-export paths won't connect +- Trait methods can't be resolved to their trait definition +- Nested `use` trees (`use foo::{bar::{A, B}, baz::C}`) aren't parsed correctly +- Glob imports (`use foo::*`) — refs won't show import origin +- Macro-generated symbols (e.g. `sqlx::FromRow`) — invisible to tree-sitter +- Single-char identifiers — intentionally filtered out of refs +- `callees` shows all identifiers in a function body, not just actual calls +- `import * as ns` namespace imports — member accesses through `ns.X` aren't resolved + ## Core Principles +- **Use `outline`/`body` to explore, then `Read` with offset/limit from the results before editing** — avoid reading full files - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked diff --git a/README_WORKMUX_DEV.md b/README_WORKMUX_DEV.md index 0b113e47ee..20d16d0352 100644 --- a/README_WORKMUX_DEV.md +++ b/README_WORKMUX_DEV.md @@ -192,70 +192,6 @@ sandbox: This mounts both the main EE repo (used by the main worktree) and the EE worktrees directory (used by feature worktrees) into every sandbox container. -## Cursor SSH Integration (`wmc`) - -`wm-cursor` (aliased as `wmc`) gives each worktree its own Cursor SSH remote window with an independently-focused tmux session. All windows are visible in the status bar across all Cursor terminals, but each one is focused on its own worktree. - -This uses **grouped tmux sessions** — multiple sessions that share the same window list but track focus independently: - -``` -tmux session: main <-- your main Cursor terminal -tmux session: cursor-feat-a <-- Cursor window for feat-a (focused on wm-feat-a) -tmux session: cursor-feat-b <-- Cursor window for feat-b (focused on wm-feat-b) - \__ all three share the same windows in the status bar -``` - -### Setup - -Run once from inside tmux on the remote: - -```bash -./scripts/wm-cursor setup /home/hugo/projects/windmill -``` - -This: - -1. **Merges `.vscode/settings.json`** — adds the `wm-tmux` terminal profile (auto-attaches to the `main` tmux session), disables auto port forwarding, configures forwarding for ports 8000/3000/5432, and stops rust-analyzer from auto-starting. Existing settings are preserved. -2. **Creates `.vscode/tasks.json`** — auto-starts the dev database (`start-dev-db.sh`) when the folder opens. -3. **Adds `wmc` alias to `~/.zshrc`** — so you can use `wmc` from any tmux window. -4. **Adds `eval "$(wmc completions)"`** to `~/.zshrc` — provides tab-completion for subcommands and worktree names (for `open`, `open-ee`, and `close`). - -After setup, reopen Cursor's terminal to pick up the new profile. - -### Usage - -All commands run from inside a tmux session (i.e., from Cursor's integrated terminal after setup). - -**Create a new worktree + open Cursor:** - -```bash -wmc add -A -p "implement feature X" -``` - -This runs `workmux add`, creates a grouped tmux session, writes `.vscode/settings.json` in the worktree (with port forwarding matching the worktree's assigned ports), and opens a new Cursor window. - -**Open Cursor for an existing worktree:** - -```bash -wmc open my-feature -``` - -**Open the EE worktree in Cursor (no tmux session):** - -```bash -wmc open-ee my-feature -``` - -This finds the matching `windmill-ee-private__worktrees/` directory and opens it in a new Cursor window. - -**Close a worktree's Cursor window and tmux window (keeps the worktree):** - -```bash -wmc close my-feature -``` - -This kills the grouped tmux session and calls `workmux close` to close the tmux window. The worktree and branch are preserved. Grouped sessions are also automatically cleaned up when you `workmux rm` a worktree (via `scripts/worktree-cleanup`). - ## Cargo Features To build the backend with specific Cargo features (e.g., `enterprise`, `parquet`), pass them via `CARGO_FEATURES`. The backend pane reads this from `.env.local` and appends `--features ` to the `cargo watch` command. @@ -270,20 +206,6 @@ CARGO_FEATURES="enterprise,parquet" wm add my-feature This gets written to `.env.local` by the `post_create` hook (`scripts/worktree-env`), and the backend pane picks it up automatically. -**With `wmc` (wm-cursor):** - -Use the `--features` flag: - -```bash -# Create a new worktree with features -wmc add --features "enterprise,parquet" -A -p "implement feature X" - -# Open an existing worktree with different features -wmc open my-feature --features "enterprise,parquet" -``` - -The `--features` flag exports `CARGO_FEATURES` so the `post_create` hook writes it to `.env.local`. When using `wmc open`, it updates the existing `.env.local` with the new features. - ## Login Default credentials: `admin@windmill.dev` / `changeme` diff --git a/docs/enterprise.md b/docs/enterprise.md index bfed61a2e6..5e2b7c9b61 100644 --- a/docs/enterprise.md +++ b/docs/enterprise.md @@ -15,17 +15,22 @@ - Standard location: `~/windmill-ee-private` - Worktree location: `~/windmill-ee-private__worktrees//` +## Detecting EE Changes + +The `*_ee.rs` files in the windmill repo are symlinks — changes won't appear in `git diff` of the windmill repo. Check the EE repo directly: `git -C status --short` + ## EE PR Workflow (MUST DO when modifying `*_ee.rs` files) When you modify any `*_ee.rs` file and create a PR on windmill: -1. **Create a matching branch** in `windmill-ee-private` (same branch name) -2. **Commit and push** the `_ee.rs` changes in that branch -3. **Create a PR** on `windmill-ee-private` with a link to the companion windmill PR -4. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` +1. **Prefix the windmill PR title** with `[ee]`: `[ee] : ` +2. **Create a matching branch** in `windmill-ee-private` (same branch name) +3. **Commit and push** the `_ee.rs` changes in that branch +4. **Create a companion PR** on `windmill-ee-private` with a link to the windmill PR (no `[ee]` prefix on this one) +5. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` - **Verify** it wrote the correct commit hash from your branch, not from main (the script may fall back to `~/windmill-ee-private` on main) - If wrong, manually write the correct hash -5. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref +6. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref ## Validation diff --git a/scripts/wm-cursor b/scripts/wm-cursor deleted file mode 100755 index 3c47da1179..0000000000 --- a/scripts/wm-cursor +++ /dev/null @@ -1,401 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -setopt err_exit no_unset pipe_fail - -# wm-cursor: Manage Cursor SSH remote windows with grouped tmux sessions -# Each worktree gets its own Cursor window with an independently-focused -# grouped tmux session, sharing the same window list in the status bar. - -# --- Resolve script path (must be at top level, not inside a function) --- - -local script_path=${0:A} - -# --- Lazy Cursor CLI resolution (only when needed) --- - -local cursor_bin= - -resolve_cursor_cli() { - [[ -n $cursor_bin ]] && return 0 - - local -a cursor_bins=(~/.cursor-server/cli/servers/*/server/bin/remote-cli/cursor(NOm)) - if (( ${#cursor_bins} == 0 )); then - print -u2 "Error: Cursor remote CLI not found in ~/.cursor-server/cli/servers/" - exit 1 - fi - cursor_bin=${cursor_bins[1]} - - # Refresh Cursor IPC socket (tmux may hold a stale one) - # Multiple stale sockets may exist; probe to find a live one - local sock - for sock in /tmp/vscode-ipc-*.sock(NOm); do - if timeout 2 env VSCODE_IPC_HOOK_CLI=$sock $cursor_bin --status &>/dev/null; then - export VSCODE_IPC_HOOK_CLI=$sock - break - fi - done -} - -# --- Helper functions --- - -ensure_tmux() { - if [[ -z ${TMUX-} ]]; then - print -u2 "Error: Not inside a tmux session" - exit 1 - fi -} - -check_dev_db() { - if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^windmill-db-dev$'; then - print -u2 "Warning: windmill-db-dev container is not running" - fi -} - -setup_grouped_session() { - local handle=$1 worktree_path=$2 - local session_name=cursor-${handle} - - # Detect the current main tmux session - local main_session=$(tmux display-message -p '#S') - - # Create grouped session (shares windows with the main session) - if ! tmux has-session -t $session_name 2>/dev/null; then - tmux new-session -d -t $main_session -s $session_name - fi - - # Focus on the worktree's window - tmux select-window -t ${session_name}:wm-${handle} 2>/dev/null || true - - # Write .vscode/settings.json in the worktree if it doesn't already exist - local settings_file=${worktree_path}/.vscode/settings.json - if [[ ! -f $settings_file ]]; then - mkdir -p ${settings_file:h} - - # Read ports from .env.local if available - local env_file=${worktree_path}/.env.local - local ports_config="" - if [[ -f $env_file ]]; then - local backend_port frontend_port - source $env_file - backend_port=${BACKEND_PORT-} - frontend_port=${FRONTEND_PORT-} - if [[ -n $backend_port && -n $frontend_port ]]; then - ports_config=', - "remote.autoForwardPorts": true, - "remote.otherPortsAttributes": { - "onAutoForward": "ignore" - }, - "remote.portsAttributes": { - "'$backend_port'": { "label": "Backend", "onAutoForward": "silent" }, - "'$frontend_port'": { "label": "Frontend", "onAutoForward": "openBrowserOnce" } - }' - fi - fi - - cat > $settings_file < from args, exports CARGO_FEATURES, returns remaining args. -# Usage: parse_features_flag "$@"; set -- "${remaining_args[@]}" - -parse_flags() { - remaining_args=() - while (( $# )); do - case $1 in - --features) - if (( $# < 2 )); then - print -u2 "Error: --features requires a value" - exit 1 - fi - export CARGO_FEATURES=$2 - shift 2 - ;; - --features=*) - export CARGO_FEATURES=${1#--features=} - shift - ;; - --clone-db) - export WM_CLONE_DB=1 - shift - ;; - *) - remaining_args+=("$1") - shift - ;; - esac - done -} - -# --- Subcommands --- - -cmd_add() { - resolve_cursor_cli - ensure_tmux - check_dev_db - - parse_flags "$@" - set -- "${remaining_args[@]}" - - # Snapshot worktree list before - local -a before=("${(@f)$(git worktree list --porcelain | grep '^worktree ')}") - - workmux add -b "$@" - - # Diff to find the new entry - local -a after=("${(@f)$(git worktree list --porcelain | grep '^worktree ')}") - local -a new=(${after:|before}) - - if (( ${#new} == 0 )); then - print -u2 "Error: Could not detect new worktree path" - exit 1 - fi - - local new_path=${new[1]#worktree } - local handle=${new_path:t} - - print "New worktree: ${handle} at ${new_path}" - setup_grouped_session $handle $new_path - $cursor_bin -n $new_path - print "Opened Cursor for ${handle}" -} - -cmd_open() { - local name=${1:?Usage: wm-cursor open } - shift - - resolve_cursor_cli - ensure_tmux - check_dev_db - - parse_flags "$@" - set -- "${remaining_args[@]}" - - # Write CARGO_FEATURES to .env.local if specified - if [[ -n ${CARGO_FEATURES-} ]]; then - local wt_env=$(workmux path $name)/.env.local - if [[ -f $wt_env ]]; then - # Remove existing CARGO_FEATURES line and append new one - sed -i '/^CARGO_FEATURES=/d' $wt_env - echo "CARGO_FEATURES=$CARGO_FEATURES" >> $wt_env - fi - fi - - local wt_path=$(workmux path $name) - local prev_target=$(tmux display-message -p '#{session_name}:#{window_index}') - - workmux open $name "$@" - tmux select-window -t $prev_target - setup_grouped_session $name $wt_path - $cursor_bin -n $wt_path - print "Opened Cursor for ${name}" -} - -cmd_close() { - local name=${1:?Usage: wm-cursor close } - - tmux kill-session -t cursor-${name} 2>/dev/null || true - workmux close $name -} - -cmd_open_ee() { - local name=${1:?Usage: wm-cursor open-ee } - - resolve_cursor_cli - local wt_path=$(workmux path $name) - local main_repo_root="$(cd "$(git -C "$wt_path" rev-parse --git-common-dir 2>/dev/null)/.." && pwd)" - - # Find ee repo (same discovery logic as worktree-env) - local ee_repo="" candidate - for candidate in \ - "${main_repo_root:+${main_repo_root}/../windmill-ee-private}" \ - "${wt_path}/../windmill-ee-private" \ - "${HOME}/windmill-ee-private" \ - "${HOME}/projects/windmill-ee-private"; do - if [[ -n $candidate ]] && [[ -d $candidate ]]; then - ee_repo=${candidate:A} - break - fi - done - - if [[ -z $ee_repo ]]; then - print -u2 "Error: Could not find windmill-ee-private repo" - exit 1 - fi - - local ee_worktree_dir="${ee_repo}__worktrees/${name}" - if [[ ! -d $ee_worktree_dir ]]; then - print -u2 "Error: EE worktree not found at ${ee_worktree_dir}" - exit 1 - fi - - $cursor_bin -n $ee_worktree_dir - print "Opened Cursor for EE worktree: ${ee_worktree_dir}" -} - -cmd_setup() { - local repo_root=${1:?Usage: wm-cursor setup } - repo_root=${repo_root:A} - local vscode_dir=${repo_root}/.vscode - - mkdir -p $vscode_dir - - # --- tasks.json --- - local tasks_file=${vscode_dir}/tasks.json - local write_tasks=true - if [[ -f $tasks_file ]]; then - print -n "tasks.json already exists. Overwrite? [y/N] " - read -q || { print; write_tasks=false } - print - fi - if $write_tasks; then - cat > $tasks_file <<'TASKS' -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Start dev DB", - "type": "shell", - "command": "./start-dev-db.sh", - "options": { "shell": { "executable": "/bin/bash" } }, - "runOptions": { "runOn": "folderOpen" }, - "presentation": { "reveal": "silent", "close": true }, - "problemMatcher": [] - } - ] -} -TASKS - print "Wrote ${tasks_file}" - fi - - # --- settings.json (merge wm-cursor keys, preserve existing) --- - local settings_file=${vscode_dir}/settings.json - local wmc_settings=' - { - "rust-analyzer.initializeStopped": true, - "terminal.integrated.defaultProfile.linux": "wm-tmux", - "terminal.integrated.profiles.linux": { - "wm-tmux": { - "path": "tmux", - "args": ["new-session", "-A", "-s", "main"] - } - }, - "remote.autoForwardPorts": true, - "remote.otherPortsAttributes": { - "onAutoForward": "ignore" - }, - "remote.portsAttributes": { - "8000": { "label": "Backend", "onAutoForward": "silent" }, - "3000": { "label": "Frontend", "onAutoForward": "openBrowserOnce" }, - "5432": { "label": "PostgreSQL", "onAutoForward": "silent" } - } - }' - - if [[ -f $settings_file ]]; then - # Strip // comments so jq can parse, merge, then write back - local existing - existing=$(python3 -c ' -import json, re, sys -text = sys.stdin.read() -# Remove // comments only outside of strings -text = re.sub(r'"'"'("(?:[^"\\]|\\.)*")|//[^\n]*'"'"', lambda m: m.group(1) or "", text) -json.dump(json.loads(text), sys.stdout, indent=2) -' < $settings_file) - jq --argjson wmc "$wmc_settings" '. * $wmc' <<< "$existing" > ${settings_file}.tmp \ - && mv ${settings_file}.tmp $settings_file - print "Merged wm-cursor settings into ${settings_file}" - else - jq . <<< "$wmc_settings" > $settings_file - print "Created ${settings_file}" - fi - - # --- zsh alias + completion --- - local rc=${ZDOTDIR:-$HOME}/.zshrc - local alias_line="alias wmc=${(q)script_path}" - - if [[ -f $rc ]] && grep -qF 'alias wmc=' $rc; then - sed -i "s|^alias wmc=.*|${alias_line}|" $rc - print "Updated wmc alias in ${rc}" - else - print "\n# wm-cursor alias\n${alias_line}" >> $rc - print "Added wmc alias to ${rc}" - fi - - local eval_line='eval "$(wmc completions)"' - if ! grep -qF 'wmc completions' $rc; then - print "${eval_line}" >> $rc - print "Added completions eval to ${rc}" - fi -} - -cmd_completions() { - cat <<'COMP' -_wmc_worktree_names() { - local -a names - names=(${(f)"$(git worktree list --porcelain 2>/dev/null | sed -n 's|^worktree .*/||p' | tail -n +2)"}) - _describe 'worktree' names -} - -_wmc() { - local -a subcmds=( - 'add:Create worktree + open Cursor' - 'open:Open Cursor for existing worktree' - 'open-ee:Open EE worktree in Cursor' - 'close:Clean up grouped tmux session' - 'setup:Set up .vscode settings, tasks + wmc alias' - 'completions:Print zsh completions' - ) - - if (( CURRENT == 2 )); then - _describe 'subcommand' subcmds - else - case $words[2] in - open|open-ee|close) - _wmc_worktree_names - ;; - esac - fi -} - -compdef _wmc wmc wm-cursor -COMP -} - -# --- Main --- - -case ${1-} in - add) shift; cmd_add "$@" ;; - open) shift; cmd_open "$@" ;; - open-ee) shift; cmd_open_ee "$@" ;; - close) shift; cmd_close "$@" ;; - setup) shift; cmd_setup "$@" ;; - completions) cmd_completions ;; - *) - print -u2 "Usage: wm-cursor [args...]" - print -u2 "" - print -u2 "Subcommands:" - print -u2 " add [--features ] [workmux-add-args...] Create worktree + open Cursor" - print -u2 " open [--features ] Open Cursor for existing worktree" - print -u2 " open-ee Open EE worktree in Cursor" - print -u2 " close Clean up grouped tmux session" - print -u2 " setup Set up .vscode settings, tasks + wmc alias" - print -u2 " completions Print zsh completions (use with eval)" - print -u2 "" - print -u2 "Options:" - print -u2 " --features Cargo features for the backend (e.g. \"enterprise,parquet\")" - print -u2 " --clone-db Clone the main 'windmill' database instead of creating an empty one" - exit 1 - ;; -esac diff --git a/scripts/worktree-common.sh b/scripts/worktree-common.sh index 91ba00a5e1..598469aad7 100755 --- a/scripts/worktree-common.sh +++ b/scripts/worktree-common.sh @@ -80,6 +80,13 @@ wm_copy_dependencies() { && echo "CLI deps installed and client generated" \ || echo "WARNING: CLI setup failed" >&2 fi + + local nav_bin="${main_repo_root}/wm-ts-nav/target/release/wm-ts-nav" + if [[ -f "$nav_bin" ]]; then + mkdir -p "${repo_root}/wm-ts-nav/target/release" + cp "$nav_bin" "${repo_root}/wm-ts-nav/target/release/" + echo "Copied wm-ts-nav binary" + fi } wm_allow_direnv() { @@ -261,5 +268,4 @@ wm_shared_pre_remove() { fi fi - tmux kill-session -t "cursor-${wt_basename}" 2>/dev/null || true } diff --git a/wm-ts-nav/Cargo.lock b/wm-ts-nav/Cargo.lock new file mode 100644 index 0000000000..b0f1343e5d --- /dev/null +++ b/wm-ts-nav/Cargo.lock @@ -0,0 +1,620 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wm-ts-nav" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ignore", + "rayon", + "rusqlite", + "serde", + "serde_json", + "tree-sitter", + "tree-sitter-rust", + "tree-sitter-typescript", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/wm-ts-nav/Cargo.toml b/wm-ts-nav/Cargo.toml new file mode 100644 index 0000000000..0dcf2c5081 --- /dev/null +++ b/wm-ts-nav/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wm-ts-nav" +version = "0.1.0" +edition = "2021" + +[dependencies] +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-typescript = "0.23" + +rusqlite = { version = "0.32", features = ["bundled"] } +rayon = "1.10" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +ignore = "0.4" + +[profile.release] +opt-level = 2 +lto = "thin" diff --git a/wm-ts-nav/nav b/wm-ts-nav/nav new file mode 100755 index 0000000000..3d0bbf3557 --- /dev/null +++ b/wm-ts-nav/nav @@ -0,0 +1,10 @@ +#!/bin/sh +# Auto-rebuilding wrapper for wm-ts-nav +DIR="$(cd "$(dirname "$0")" && pwd)" +BIN="$DIR/target/release/wm-ts-nav" + +if [ ! -f "$BIN" ] || [ -n "$(find "$DIR/src" "$DIR/Cargo.toml" -newer "$BIN" 2>/dev/null | head -1)" ]; then + cargo build --release --manifest-path "$DIR/Cargo.toml" >&2 || exit 1 +fi + +exec "$BIN" "$@" diff --git a/wm-ts-nav/src/db.rs b/wm-ts-nav/src/db.rs new file mode 100644 index 0000000000..eb6556262c --- /dev/null +++ b/wm-ts-nav/src/db.rs @@ -0,0 +1,422 @@ +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::parser::{IdentRef, Symbol}; + +pub struct Db { + conn: Connection, +} + +impl Db { + pub fn open(cache_dir: &Path) -> Result { + std::fs::create_dir_all(cache_dir) + .with_context(|| format!("creating cache dir: {}", cache_dir.display()))?; + let db_path = cache_dir.join("index.db"); + let conn = Connection::open(&db_path) + .with_context(|| format!("opening db: {}", db_path.display()))?; + + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + mtime_secs INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS symbols ( + id INTEGER PRIMARY KEY, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL, + line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + signature TEXT, + parent TEXT + ); + CREATE TABLE IF NOT EXISTS refs ( + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + name TEXT NOT NULL, + line INTEGER NOT NULL, + import_path TEXT + ); + CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name); + CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id); + CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind); + CREATE INDEX IF NOT EXISTS idx_files_path ON files(path); + CREATE INDEX IF NOT EXISTS idx_refs_name ON refs(name); + CREATE INDEX IF NOT EXISTS idx_refs_file ON refs(file_id);", + )?; + + Ok(Self { conn }) + } + + pub fn begin(&self) -> Result<()> { + self.conn.execute_batch("BEGIN")?; + Ok(()) + } + + pub fn commit(&self) -> Result<()> { + self.conn.execute_batch("COMMIT")?; + Ok(()) + } + + pub fn upsert_file( + &self, + path: &str, + mtime_secs: i64, + symbols: &[Symbol], + refs: &[IdentRef], + ) -> Result<()> { + // Delete old entry if exists + self.conn.execute( + "DELETE FROM refs WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn.execute( + "DELETE FROM symbols WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn + .execute("DELETE FROM files WHERE path = ?1", params![path])?; + + // Insert new file + self.conn.execute( + "INSERT INTO files (path, mtime_secs) VALUES (?1, ?2)", + params![path, mtime_secs], + )?; + let file_id = self.conn.last_insert_rowid(); + + // Insert symbols + let mut stmt = self.conn.prepare_cached( + "INSERT INTO symbols (file_id, name, kind, line, end_line, signature, parent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + )?; + for sym in symbols { + stmt.execute(params![ + file_id, + sym.name, + sym.kind, + sym.line, + sym.end_line, + sym.signature, + sym.parent, + ])?; + } + + // Insert refs + let mut ref_stmt = self.conn.prepare_cached( + "INSERT INTO refs (file_id, name, line, import_path) VALUES (?1, ?2, ?3, ?4)", + )?; + for r in refs { + ref_stmt.execute(params![file_id, r.name, r.line, r.import_path])?; + } + + Ok(()) + } + + pub fn remove_file(&self, path: &str) -> Result<()> { + self.conn.execute( + "DELETE FROM refs WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn.execute( + "DELETE FROM symbols WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn + .execute("DELETE FROM files WHERE path = ?1", params![path])?; + Ok(()) + } + + pub fn all_indexed_paths(&self) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT path, mtime_secs FROM files")?; + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?; + Ok(rows) + } + + pub fn search_symbols( + &self, + pattern: &str, + kind_filter: Option<&str>, + parent_filter: Option<&str>, + limit: usize, + ) -> Result> { + let mut conditions = vec!["s.name LIKE ?1".to_string()]; + if let Some(kind) = kind_filter { + conditions.push(format!("s.kind = '{kind}'")); + } + if let Some(parent) = parent_filter { + conditions.push(format!("s.parent LIKE '%{parent}%'")); + } + let where_clause = conditions.join(" AND "); + let query = format!( + "SELECT s.name, s.kind, s.line, s.end_line, s.signature, s.parent, f.path + FROM symbols s JOIN files f ON s.file_id = f.id + WHERE {where_clause} + ORDER BY s.name LIMIT ?2" + ); + + let like_pattern = if pattern.contains('%') || pattern.contains('_') { + pattern.to_string() + } else { + format!("%{pattern}%") + }; + + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt + .query_map(params![like_pattern, limit as i64], |row| { + Ok(SearchResult { + name: row.get(0)?, + kind: row.get(1)?, + line: row.get(2)?, + end_line: row.get(3)?, + signature: row.get(4)?, + parent: row.get(5)?, + path: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + pub fn file_symbols(&self, path: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT s.name, s.kind, s.line, s.end_line, s.signature, s.parent, f.path + FROM symbols s JOIN files f ON s.file_id = f.id + WHERE f.path = ?1 + ORDER BY s.line", + )?; + let rows = stmt + .query_map(params![path], |row| { + Ok(SearchResult { + name: row.get(0)?, + kind: row.get(1)?, + line: row.get(2)?, + end_line: row.get(3)?, + signature: row.get(4)?, + parent: row.get(5)?, + path: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + pub fn find_refs( + &self, + name: &str, + limit: usize, + file_filter: Option<&str>, + with_caller: bool, + ) -> Result> { + let mut conditions = vec!["r.name = ?1".to_string()]; + if let Some(file) = file_filter { + conditions.push(format!("f.path LIKE '%{}'", file.replace('\'', "''"))); + } + let where_clause = conditions.join(" AND "); + + if with_caller { + let query = format!( + "SELECT path, line, import_path, caller_name, caller_kind FROM ( + SELECT f.path, r.line, r.import_path, s.name AS caller_name, s.kind AS caller_kind, + ROW_NUMBER() OVER ( + PARTITION BY r.file_id, r.line + ORDER BY (s.end_line - s.line) ASC + ) AS rn + FROM refs r + JOIN files f ON r.file_id = f.id + LEFT JOIN symbols s ON s.file_id = r.file_id + AND s.line <= r.line AND r.line <= s.end_line + AND s.kind IN ('function', 'impl', 'class', 'interface', 'method') + WHERE {where_clause} + ) WHERE rn = 1 + ORDER BY path, line + LIMIT ?2" + ); + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt + .query_map(params![name, limit as i64], |row| { + Ok(RefResult { + path: row.get(0)?, + line: row.get(1)?, + import_path: row.get(2)?, + caller_name: row.get(3)?, + caller_kind: row.get(4)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } else { + let query = format!( + "SELECT f.path, r.line, r.import_path + FROM refs r JOIN files f ON r.file_id = f.id + WHERE {where_clause} + ORDER BY f.path, r.line + LIMIT ?2" + ); + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt + .query_map(params![name, limit as i64], |row| { + Ok(RefResult { + path: row.get(0)?, + line: row.get(1)?, + import_path: row.get(2)?, + caller_name: None, + caller_kind: None, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + } + + pub fn find_callers(&self, name: &str, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT caller_name, caller_kind, caller_line, caller_end_line, path, ref_line FROM ( + SELECT s.name AS caller_name, s.kind AS caller_kind, + s.line AS caller_line, s.end_line AS caller_end_line, + f.path, r.line AS ref_line, + ROW_NUMBER() OVER ( + PARTITION BY r.file_id, r.line + ORDER BY (s.end_line - s.line) ASC + ) AS rn + FROM refs r + JOIN symbols s ON s.file_id = r.file_id + AND s.line <= r.line AND r.line <= s.end_line + AND s.kind IN ('function', 'impl', 'class', 'interface', 'method') + JOIN files f ON r.file_id = f.id + WHERE r.name = ?1 + ) WHERE rn = 1 + ORDER BY path, caller_line + LIMIT ?2", + )?; + let rows = stmt + .query_map(params![name, limit as i64], |row| { + Ok(CallerResult { + caller_name: row.get(0)?, + caller_kind: row.get(1)?, + caller_line: row.get(2)?, + caller_end_line: row.get(3)?, + path: row.get(4)?, + ref_line: row.get(5)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + pub fn find_callees( + &self, + name: &str, + kind_filter: Option<&str>, + file_filter: Option<&str>, + ) -> Result> { + // First find the symbol + let results = self.search_symbols(name, kind_filter, None, 100)?; + let exact: Vec<_> = results.into_iter().filter(|r| r.name == name).collect(); + if exact.is_empty() { + return Ok(vec![]); + } + + let mut all_callees = Vec::new(); + for sym in &exact { + if let Some(file) = file_filter { + if !sym.path.contains(file) { + continue; + } + } + let mut stmt = self.conn.prepare( + "SELECT DISTINCT r.name, r.import_path + FROM refs r + JOIN files f ON r.file_id = f.id + WHERE f.path = ?1 AND r.line >= ?2 AND r.line <= ?3 + ORDER BY r.name", + )?; + let rows = stmt + .query_map(params![sym.path, sym.line, sym.end_line], |row| { + Ok(CalleeResult { + name: row.get(0)?, + import_path: row.get(1)?, + }) + })? + .collect::, _>>()?; + all_callees.extend(rows); + } + // Deduplicate by name + all_callees.sort_by(|a, b| a.name.cmp(&b.name)); + all_callees.dedup_by(|a, b| a.name == b.name); + Ok(all_callees) + } +} + +#[derive(Debug, serde::Serialize)] +pub struct RefResult { + pub path: String, + pub line: i64, + pub import_path: Option, + pub caller_name: Option, + pub caller_kind: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct CallerResult { + pub caller_name: String, + pub caller_kind: String, + pub caller_line: i64, + pub caller_end_line: i64, + pub path: String, + pub ref_line: i64, +} + +#[derive(Debug, serde::Serialize)] +pub struct CalleeResult { + pub name: String, + pub import_path: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct SearchResult { + pub name: String, + pub kind: String, + pub line: i64, + pub end_line: i64, + pub signature: Option, + pub parent: Option, + pub path: String, +} + +pub fn mtime_secs(path: &Path) -> Result { + let meta = std::fs::metadata(path)?; + let mtime = meta + .modified()? + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + Ok(mtime.as_secs() as i64) +} + +pub fn cache_dir_for(root: &Path) -> PathBuf { + let hash = { + let s = root.to_string_lossy(); + let mut h: u64 = 5381; + for b in s.bytes() { + h = h.wrapping_mul(33).wrapping_add(b as u64); + } + h + }; + dirs_cache().join(format!("{hash:x}")) +} + +fn dirs_cache() -> PathBuf { + if let Ok(d) = std::env::var("XDG_CACHE_HOME") { + PathBuf::from(d).join("wm-ts-nav") + } else if let Ok(d) = std::env::var("HOME") { + PathBuf::from(d).join(".cache").join("wm-ts-nav") + } else { + PathBuf::from("/tmp/wm-ts-nav") + } +} diff --git a/wm-ts-nav/src/indexer.rs b/wm-ts-nav/src/indexer.rs new file mode 100644 index 0000000000..ef69fbd1f4 --- /dev/null +++ b/wm-ts-nav/src/indexer.rs @@ -0,0 +1,102 @@ +use anyhow::Result; +use ignore::WalkBuilder; +use rayon::prelude::*; +use std::collections::HashSet; +use std::path::Path; + +use crate::db::{self, Db}; +use crate::parser::{self, Lang}; + +pub struct IndexStats { + pub files_scanned: usize, + pub files_updated: usize, + pub files_removed: usize, + pub files_unchanged: usize, +} + +/// Incrementally update the index for the given root directory. +/// Only re-parses files whose mtime has changed since last index. +pub fn update_index(db: &Db, root: &Path) -> Result { + // Collect all supported files using `ignore` crate (respects .gitignore) + let files: Vec<_> = WalkBuilder::new(root) + .hidden(true) + .git_ignore(true) + .git_global(false) + .build() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .filter(|e| Lang::from_path(e.path()).is_some()) + .map(|e| e.into_path()) + .collect(); + + let disk_paths: HashSet = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + // Check which files need updating + let existing = db.all_indexed_paths()?; + // Remove files no longer on disk + let mut files_removed = 0; + db.begin()?; + for (path, _) in &existing { + if !disk_paths.contains(path) { + db.remove_file(path)?; + files_removed += 1; + } + } + db.commit()?; + + // Figure out which files need re-parsing + let existing_map: std::collections::HashMap<&str, i64> = existing + .iter() + .map(|(p, m)| (p.as_str(), *m)) + .collect(); + + let to_parse: Vec<_> = files + .iter() + .filter(|path| { + let path_str = path.to_string_lossy(); + match existing_map.get(path_str.as_ref()) { + Some(&old_mtime) => { + // Check if mtime changed + db::mtime_secs(path).unwrap_or(0) != old_mtime + } + None => true, // New file + } + }) + .collect(); + + let files_unchanged = files.len() - to_parse.len(); + + // Parse files in parallel + let results: Vec<_> = to_parse + .par_iter() + .filter_map(|path| { + let mtime = db::mtime_secs(path).ok()?; + match parser::parse_file(path) { + Ok(result) => Some((path.to_string_lossy().to_string(), mtime, result)), + Err(e) => { + eprintln!("warning: failed to parse {}: {e}", path.display()); + None + } + } + }) + .collect(); + + let files_updated = results.len(); + + // Write to db in a single transaction + db.begin()?; + for (path, mtime, result) in &results { + db.upsert_file(path, *mtime, &result.symbols, &result.refs)?; + } + db.commit()?; + + Ok(IndexStats { + files_scanned: files.len(), + files_updated, + files_removed, + files_unchanged, + }) +} diff --git a/wm-ts-nav/src/main.rs b/wm-ts-nav/src/main.rs new file mode 100644 index 0000000000..8ae8c11f5b --- /dev/null +++ b/wm-ts-nav/src/main.rs @@ -0,0 +1,270 @@ +mod db; +mod indexer; +mod parser; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "wm-ts-nav", about = "Tree-sitter code navigator for Windmill")] +struct Cli { + /// Root directory to index (defaults to current directory) + #[arg(short, long)] + root: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Index/re-index the codebase + Index, + /// Show symbols in a file + Outline { + /// File path + file: PathBuf, + }, + /// Search symbols by name pattern + Search { + /// Name pattern (supports SQL LIKE % wildcards) + pattern: String, + /// Filter by kind (function, struct, enum, trait, impl, etc.) + #[arg(short, long)] + kind: Option, + /// Filter by parent (e.g. --parent ServiceName to find methods on that type) + #[arg(short, long)] + parent: Option, + /// Max results + #[arg(short, long, default_value = "50")] + limit: usize, + }, + /// Find symbol definition by exact name + Def { + /// Exact symbol name + name: String, + /// Filter by kind + #[arg(short, long)] + kind: Option, + }, + /// Find references to a symbol in code (skips comments and strings) + Refs { + /// Symbol name to find + name: String, + /// Max results + #[arg(short, long, default_value = "50")] + limit: usize, + /// Filter to files matching this substring + #[arg(short, long)] + file: Option, + /// Show which function/symbol contains each reference + #[arg(short, long)] + caller: bool, + }, + /// Extract and print a symbol's source code + Body { + /// Exact symbol name + name: String, + /// Filter by kind + #[arg(short, long)] + kind: Option, + /// Filter to files matching this substring + #[arg(short, long)] + file: Option, + }, + /// Find what calls a symbol (who calls X?) + Callers { + /// Symbol name to find callers of + name: String, + /// Max results + #[arg(short, long, default_value = "50")] + limit: usize, + }, + /// Find what a symbol calls (what does X call?) + Callees { + /// Exact symbol name + name: String, + /// Filter by kind + #[arg(short, long)] + kind: Option, + /// Filter to files matching this substring + #[arg(short, long)] + file: Option, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let root = cli + .root + .unwrap_or_else(|| std::env::current_dir().expect("no cwd")); + let root = std::fs::canonicalize(&root)?; + let cache_dir = db::cache_dir_for(&root); + let db = db::Db::open(&cache_dir)?; + + // Always update index incrementally before any query + let stats = indexer::update_index(&db, &root)?; + + match cli.command { + Command::Index => { + println!( + "Indexed {} files: {} updated, {} unchanged, {} removed", + stats.files_scanned, stats.files_updated, stats.files_unchanged, stats.files_removed + ); + } + Command::Outline { file } => { + let file = std::fs::canonicalize(&file)?; + let symbols = db.file_symbols(&file.to_string_lossy())?; + if symbols.is_empty() { + println!("No symbols found"); + return Ok(()); + } + for s in &symbols { + let parent = s + .parent + .as_deref() + .map(|p| format!(" [{p}]")) + .unwrap_or_default(); + let sig = s + .signature + .as_deref() + .map(|s| format!(" {s}")) + .unwrap_or_default(); + println!("L{}-{} {:12} {}{}{}", s.line, s.end_line, s.kind, s.name, parent, sig); + } + } + Command::Search { + pattern, + kind, + parent, + limit, + } => { + let results = db.search_symbols(&pattern, kind.as_deref(), parent.as_deref(), limit)?; + if results.is_empty() { + println!("No symbols matching '{pattern}'"); + return Ok(()); + } + for r in &results { + let sig = r + .signature + .as_deref() + .map(|s| format!(" {s}")) + .unwrap_or_default(); + let parent_info = r + .parent + .as_deref() + .map(|p| format!(" [{p}]")) + .unwrap_or_default(); + println!("{}:{} {:12} {}{}{}", r.path, r.line, r.kind, r.name, parent_info, sig); + } + } + Command::Def { name, kind } => { + let results = db.search_symbols(&name, kind.as_deref(), None, 100)?; + let exact: Vec<_> = results.iter().filter(|r| r.name == name).collect(); + if exact.is_empty() { + println!("No definition found for '{name}'"); + return Ok(()); + } + for r in &exact { + let sig = r + .signature + .as_deref() + .map(|s| format!("\n {s}")) + .unwrap_or_default(); + let parent = r + .parent + .as_deref() + .map(|p| format!(" [{p}]")) + .unwrap_or_default(); + println!( + "{}:L{}-{} {} {}{}{}", + r.path, r.line, r.end_line, r.kind, r.name, parent, sig + ); + } + } + Command::Refs { + name, + limit, + file, + caller, + } => { + let results = db.find_refs(&name, limit, file.as_deref(), caller)?; + if results.is_empty() { + println!("No references found for '{name}'"); + return Ok(()); + } + for r in &results { + let origin = r + .import_path + .as_deref() + .map(|p| format!(" ({p})")) + .unwrap_or_default(); + let caller_info = r + .caller_name + .as_deref() + .map(|c| format!(" [{c}]")) + .unwrap_or_default(); + println!("{}:{}{}{}", r.path, r.line, caller_info, origin); + } + } + Command::Body { name, kind, file } => { + let results = db.search_symbols(&name, kind.as_deref(), None, 100)?; + let mut exact: Vec<_> = results.into_iter().filter(|r| r.name == name).collect(); + if let Some(ref f) = file { + exact.retain(|r| r.path.contains(f.as_str())); + } + if exact.is_empty() { + println!("No definition found for '{name}'"); + return Ok(()); + } + for (i, r) in exact.iter().enumerate() { + if i > 0 { + println!("\n---\n"); + } + println!("{}:L{}-{}", r.path, r.line, r.end_line); + match std::fs::read_to_string(&r.path) { + Ok(contents) => { + let lines: Vec<&str> = contents.lines().collect(); + let start = (r.line as usize).saturating_sub(1); + let end = (r.end_line as usize).min(lines.len()); + for line in &lines[start..end] { + println!("{line}"); + } + } + Err(e) => println!(" (error reading file: {e})"), + } + } + } + Command::Callers { name, limit } => { + let results = db.find_callers(&name, limit)?; + if results.is_empty() { + println!("No callers found for '{name}'"); + return Ok(()); + } + for r in &results { + println!( + "{}:L{}-{} {} {} → L{}", + r.path, r.caller_line, r.caller_end_line, r.caller_kind, r.caller_name, r.ref_line + ); + } + } + Command::Callees { name, kind, file } => { + let results = db.find_callees(&name, kind.as_deref(), file.as_deref())?; + if results.is_empty() { + println!("No callees found for '{name}'"); + return Ok(()); + } + for r in &results { + let origin = r + .import_path + .as_deref() + .map(|p| format!(" ({p})")) + .unwrap_or_default(); + println!("{}{}", r.name, origin); + } + } + } + + Ok(()) +} diff --git a/wm-ts-nav/src/parser.rs b/wm-ts-nav/src/parser.rs new file mode 100644 index 0000000000..43d3bfe0af --- /dev/null +++ b/wm-ts-nav/src/parser.rs @@ -0,0 +1,716 @@ +use anyhow::{Context, Result}; +use std::path::Path; +use tree_sitter::{Node, Parser}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct Symbol { + pub name: String, + pub kind: String, + pub line: usize, + pub end_line: usize, + pub signature: Option, + pub parent: Option, +} + +#[derive(Debug, Clone)] +pub struct IdentRef { + pub name: String, + pub line: usize, + /// Resolved import path if known (e.g. "windmill_common::error::Error") + pub import_path: Option, +} + +/// A `use` import with its scope +#[derive(Debug, Clone)] +pub struct ImportEntry { + /// The short name (e.g. "Error") + pub name: String, + /// Full path (e.g. "windmill_common::error::Error") + pub full_path: String, + /// Line where the use is declared + pub line: usize, + /// End of the scope this use lives in (file end for top-level, block end for scoped) + pub scope_end: usize, +} + +pub struct ParseResult { + pub symbols: Vec, + pub refs: Vec, +} + +pub enum Lang { + Rust, + Typescript, + Tsx, +} + +impl Lang { + pub fn from_path(path: &Path) -> Option { + match path.extension()?.to_str()? { + "rs" => Some(Self::Rust), + "tsx" | "jsx" => Some(Self::Tsx), + "ts" | "js" => Some(Self::Typescript), + "svelte" => Some(Self::Typescript), // we extract {#if flowStore.val?.value?.failure_module} @@ -67,7 +71,7 @@ + {/if} + + {#if smallFailureModule} - + {/if}
{:else} @@ -124,14 +142,17 @@ {#snippet trigger()} {/snippet} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index eada672f24..fc8b68afb9 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -271,6 +271,8 @@ let sidebarMode: 'list' | 'graph' = 'graph' let minHeight = $state(0) + let flowPaneWidth = $state(0) + let compactTopbar = $derived(flowPaneWidth < 700) export function selectNextId(id: any) { if (flowStore.val.value.modules) { @@ -505,11 +507,12 @@ {/each} -
+
void disableAi?: boolean diffManager?: FlowDiffManager + compact?: boolean } let { @@ -33,7 +34,8 @@ noteMode, toggleNoteMode, disableAi, - diffManager + diffManager, + compact = false }: Props = $props() const { selectionManager, flowStore } = getContext('FlowEditorContext') @@ -42,23 +44,31 @@
{#if !disableSettings} - + + + {#snippet text()} + Settings + {/snippet} + {/if} - + {#snippet text()} Error Handler {/snippet} From bc35c946161629b17483130b9634de2416235cff Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 13 Mar 2026 12:29:58 +0000 Subject: [PATCH 26/76] ci --- .github/workflows/npm_on_release.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index 6aa537060e..ea8bdeb482 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -8,17 +8,21 @@ on: jobs: publish_typescript_client: runs-on: ubicloud-standard-8 + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" - - run: cd typescript-client && ./publish.sh && cd .. - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: cd typescript-client && ./publish.sh --provenance --access public && cd .. publish_cli: runs-on: ubicloud-standard-8 + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 @@ -28,6 +32,4 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - run: cd cli && ./build.sh && cd npm && npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: cd cli && ./build.sh && cd npm && npm publish --provenance --access public From 2a8e276b6d2761bb2798b6bc5f8d90ab34fbb403 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 13 Mar 2026 12:38:41 +0000 Subject: [PATCH 27/76] fix(ci): add NODE_AUTH_TOKEN for npm publish authentication Co-Authored-By: Claude Opus 4.6 --- .github/workflows/npm_on_release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index ea8bdeb482..5f84cb9ae8 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -18,6 +18,8 @@ jobs: node-version: "20.x" registry-url: "https://registry.npmjs.org" - run: cd typescript-client && ./publish.sh --provenance --access public && cd .. + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish_cli: runs-on: ubicloud-standard-8 permissions: @@ -33,3 +35,5 @@ jobs: with: bun-version: latest - run: cd cli && ./build.sh && cd npm && npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 44dd3ee8cd05d288828d1d46c84cbcdf40f8fa78 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 13 Mar 2026 12:43:45 +0000 Subject: [PATCH 28/76] fix(ci): remove provenance flag and use NPM_TOKEN for npm publish Co-Authored-By: Claude Opus 4.6 --- .github/workflows/npm_on_release.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index 5f84cb9ae8..a41bd80854 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -8,23 +8,17 @@ on: jobs: publish_typescript_client: runs-on: ubicloud-standard-8 - permissions: - contents: read - id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" - - run: cd typescript-client && ./publish.sh --provenance --access public && cd .. + - run: cd typescript-client && ./publish.sh --access public && cd .. env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish_cli: runs-on: ubicloud-standard-8 - permissions: - contents: read - id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 @@ -34,6 +28,6 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - run: cd cli && ./build.sh && cd npm && npm publish --provenance --access public + - run: cd cli && ./build.sh && cd npm && npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 8301d86800a65bb70e5b29663c7f4d6bf15b0785 Mon Sep 17 00:00:00 2001 From: HugoCasa Date: Fri, 13 Mar 2026 17:45:06 +0100 Subject: [PATCH 29/76] docs: rewrite Code Navigation section with MUST for outline/body and condensed limitations Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f77dbb0600..1e768c2142 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Open-source platform for internal tools, workflows, API integrations, background ## Workflow -1. **Understand**: Before coding, use `wm-ts-nav` to explore (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code. Read `docs/` for domain context. +1. **Understand**: Before coding, explore the codebase (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code, `Grep` to find usages. Read `docs/` for domain context. 2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages 3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`) 4. **Validate**: After every change, run the appropriate checks per `docs/validation.md` @@ -52,11 +52,9 @@ let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props() ## Code Navigation -`wm-ts-nav` is an AST-aware code navigator. Use **Grep** for regex/pattern search. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries. +`wm-ts-nav` is an AST-aware code navigator. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries. -**Prefer wm-ts-nav over Read** to save context window: -- `outline ` instead of reading a full file — understand structure first, then `body` or Read for specifics -- `body "X"` instead of reading a full file to see one function/struct +**MUST use `outline` before `Read`** on unfamiliar files — a 500-line file costs ~500 lines of context, while `outline` costs ~20. Then **MUST use `body "X"`** instead of reading a full file to see one function/struct. Use `Read` with offset/limit only when you need surrounding context that `body` doesn't capture. - `refs "X" --caller` instead of reading files to find which function contains each reference - `callers "X"` / `callees "X"` for call-graph questions @@ -73,20 +71,14 @@ $NAV --root backend callers "X" # who calls X? $NAV --root backend callees "X" # what does X call? ``` -**Limitations** — syntax-level analysis, no type inference: -- Import paths are stored literally — `crate::X` and `super::X` pointing to the same type won't be linked -- Re-export chains (`pub use`) aren't followed — refs through different re-export paths won't connect -- Trait methods can't be resolved to their trait definition -- Nested `use` trees (`use foo::{bar::{A, B}, baz::C}`) aren't parsed correctly -- Glob imports (`use foo::*`) — refs won't show import origin -- Macro-generated symbols (e.g. `sqlx::FromRow`) — invisible to tree-sitter -- Single-char identifiers — intentionally filtered out of refs +**Limitations** — syntax-level analysis, no type inference. Use **Grep** instead when completeness matters (finding all usages, exhaustiveness checks): +- `refs`/`callers`/`callees` can't follow re-exports, glob imports, or different import paths to the same symbol +- Trait impls, macro-generated symbols (`sqlx::FromRow`), and namespace member access (`ns.X`) are invisible - `callees` shows all identifiers in a function body, not just actual calls -- `import * as ns` namespace imports — member accesses through `ns.X` aren't resolved ## Core Principles -- **Use `outline`/`body` to explore, then `Read` with offset/limit from the results before editing** — avoid reading full files +- **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked From 060687b1fa6b627a7b06fbdc4b3f4eb0b63411c0 Mon Sep 17 00:00:00 2001 From: Pyra <92104930+pyranota@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:30:23 +0100 Subject: [PATCH 30/76] fix(cli): exclude raw app backend files from script metadata generation (#8362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files inside .raw_app/backend/ were incorrectly being processed by `script generate-metadata` and `generate-metadata --skip-flows --skip-apps` because the filter only checked isFlowPath and isAppPath, but not isRawAppPath. This caused backend runnables to be treated as standalone scripts, creating incorrect .script.yaml files at wrong locations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- .../generate-metadata/generate-metadata.ts | 5 +- cli/src/commands/script/script.ts | 4 +- cli/test/raw_app_sync.test.ts | 55 ++++++++++++++++++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts index 0d7435856c..ce1b1eb051 100644 --- a/cli/src/commands/generate-metadata/generate-metadata.ts +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -19,7 +19,7 @@ import { ignoreF, } from "../sync/sync.ts"; import { exts } from "../script/script.ts"; -import { isFlowPath, isAppPath } from "../../utils/resource_folders.ts"; +import { isFlowPath, isAppPath, isRawAppPath } from "../../utils/resource_folders.ts"; import { listSyncCodebases } from "../../utils/codebase.ts"; interface StaleItem { @@ -82,7 +82,8 @@ async function generateMetadata( (!isD && !exts.some((ext) => p.endsWith(ext))) || ignore(p, isD) || isFlowPath(p) || - isAppPath(p) + isAppPath(p) || + isRawAppPath(p) ); }, false, diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 3a6556b34d..817b9947d7 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -58,6 +58,7 @@ import { isFlowInlineScriptPath as isFlowInlineScriptPathInternal, isFlowPath, isAppPath, + isRawAppPath, } from "../../utils/resource_folders.ts"; export interface ScriptFile { @@ -1027,7 +1028,8 @@ export async function generateMetadata( (!isD && !exts.some((ext) => p.endsWith(ext))) || ignore(p, isD) || isFlowPath(p) || - isAppPath(p) + isAppPath(p) || + isRawAppPath(p) ); }, false, diff --git a/cli/test/raw_app_sync.test.ts b/cli/test/raw_app_sync.test.ts index f77b4b045b..95d812511f 100644 --- a/cli/test/raw_app_sync.test.ts +++ b/cli/test/raw_app_sync.test.ts @@ -107,7 +107,7 @@ async function readFileContent(filePath: string): Promise { * Create a raw app directory structure on disk * Uses .raw_app folder suffix with raw_app.yaml metadata */ -async function createRawAppOnDisk(appDir: string): Promise { +async function createRawAppOnDisk(appDir: string, includeBackend: boolean = false): Promise { await mkdir(appDir, { recursive: true }); await mkdir(path.join(appDir, "inline_scripts"), { recursive: true }); @@ -131,6 +131,16 @@ async function createRawAppOnDisk(appDir: string): Promise { INLINE_SCRIPT_A_LOCK, "utf-8" ); + + // Optionally create backend runnable (type: inline) + if (includeBackend) { + await mkdir(path.join(appDir, "backend"), { recursive: true }); + await writeFile(path.join(appDir, "backend", "query.yaml"), "type: inline\n", "utf-8"); + await writeFile(path.join(appDir, "backend", "query.ts"), `export async function main(x: number): Promise { + return \`Result: \${x}\`; +} +`, "utf-8"); + } } test("Raw App: full sync workflow - push, pull, modify, push, clear, pull", async () => { @@ -153,7 +163,7 @@ excludes: []`, "utf-8"); // Create folder structure const appDir = path.join(tempDir, "f", "test", "my_raw_app.raw_app"); await mkdir(path.join(tempDir, "f", "test"), { recursive: true }); - await createRawAppOnDisk(appDir); + await createRawAppOnDisk(appDir, true); // Include backend for metadata test // ========================================================================= // STEP 1: Initial push - create raw app on backend @@ -266,6 +276,47 @@ excludes: []`, "utf-8"); const pulledInlineScript = await readFileContent(inlineScriptPath); expect(pulledInlineScript).toContain("modified:"); + + // ========================================================================= + // STEP 7: Test that script generate-metadata does NOT process backend runnables + // ========================================================================= + + // Create a standalone script (should be processed by script generate-metadata) + await writeFile(path.join(tempDir, "f", "test", "standalone.ts"), `export async function main(): Promise { + return "hello"; +} +`, "utf-8"); + + // Run script generate-metadata + const metaResult1 = await backend.runCLICommand( + ['script', 'generate-metadata', '--yes'], + tempDir, "raw_app_test" + ); + expect(metaResult1.code).toEqual(0); + + // Run generate-metadata --skip-flows --skip-apps + const metaResult2 = await backend.runCLICommand( + ['generate-metadata', '--skip-flows', '--skip-apps', '--yes'], + tempDir, "raw_app_test" + ); + expect(metaResult2.code).toEqual(0); + + // Backend runnables should NOT have .script.yaml files + const backendDir = path.join(appDir, "backend"); + expect(await fileExists(path.join(backendDir, "query.yaml"))).toBeTruthy(); + expect(await fileExists(path.join(backendDir, "query.ts"))).toBeTruthy(); + expect(await fileExists(path.join(backendDir, "query.script.yaml"))).toBeFalsy(); + expect(await fileExists(path.join(backendDir, "query.script.lock"))).toBeFalsy(); + + // Bug: raw app backend files get misprocessed and create script files at wrong location + // The path f/test/my_raw_app.raw_app/backend/query.ts gets truncated at first "." + // becoming f/test/my_raw_app.script.yaml (stripping .raw_app/backend/query.ts) + expect(await fileExists(path.join(tempDir, "f", "test", "my_raw_app.script.yaml"))).toBeFalsy(); + expect(await fileExists(path.join(tempDir, "f", "test", "my_raw_app.script.lock"))).toBeFalsy(); + + // Standalone script SHOULD have metadata + expect(await fileExists(path.join(tempDir, "f", "test", "standalone.script.yaml"))).toBeTruthy(); + expect(await fileExists(path.join(tempDir, "f", "test", "standalone.script.lock"))).toBeTruthy(); }); }); From 0d31c35f3e12d637c757a95fe350294002cbf640 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 13 Mar 2026 21:31:06 +0100 Subject: [PATCH 31/76] fix(frontend): filter webhook/email tokens by scope instead of label (#8363) The backend already filters tokens by scope matching the script/flow path. Remove the redundant client-side label prefix filter so that all tokens with matching scopes are shown, not just those with a specific label convention. Co-authored-by: Claude Opus 4.6 --- .../components/triggers/TriggerTokens.svelte | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/triggers/TriggerTokens.svelte b/frontend/src/lib/components/triggers/TriggerTokens.svelte index 4cf1a5d027..11d0bfca74 100644 --- a/frontend/src/lib/components/triggers/TriggerTokens.svelte +++ b/frontend/src/lib/components/triggers/TriggerTokens.svelte @@ -1,5 +1,5 @@
+ {#if workspaceDependencies.language === 'powershell'} +
+ JSON object with a "modules" key mapping module names to versions. Use + "*" + or null for latest version, or a specific version string to pin. These + modules are merged with script-level + Import-Module statements at runtime (workspace versions take precedence). +
+ {/if}
From 9554876d8b0b94516185ded1a3e34cc347e1854a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 16 Mar 2026 15:41:17 +0000 Subject: [PATCH 65/76] chore(main): release 1.658.0 (#8382) * chore(main): release 1.658.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 20 +++ backend/Cargo.lock | 158 +++++++++--------- backend/Cargo.toml | 4 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 15 files changed, 114 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0095c052ff..f3522f9c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.658.0](https://github.com/windmill-labs/windmill/compare/v1.657.2...v1.658.0) (2026-03-16) + + +### Features + +* add GET /api/saml/metadata endpoint ([#8394](https://github.com/windmill-labs/windmill/issues/8394)) ([50b24cf](https://github.com/windmill-labs/windmill/commit/50b24cfdc8bf54656adbdc3315037aa773632076)) +* support custom headers in customai resource type ([#8364](https://github.com/windmill-labs/windmill/issues/8364)) ([5acb367](https://github.com/windmill-labs/windmill/commit/5acb367cf9b4b96ac7129c91df229d1a25258f5b)) +* support multiple secret variables during resource creation ([#8386](https://github.com/windmill-labs/windmill/issues/8386)) ([54841b7](https://github.com/windmill-labs/windmill/commit/54841b7549d5c9719d4dc3cb43e282ba057cd0f3)) + + +### Bug Fixes + +* /updatesqlx now uses ee-repo-ref.txt commit hash ([#8387](https://github.com/windmill-labs/windmill/issues/8387)) ([a519d41](https://github.com/windmill-labs/windmill/commit/a519d4113086430ace1d7ac8795bd2c2a8cf99e9)) +* **native-triggers:** preserve API error response body in HttpRequestError ([#8392](https://github.com/windmill-labs/windmill/issues/8392)) ([1eee89d](https://github.com/windmill-labs/windmill/commit/1eee89d99fbf31751d6257a4015e0b22e3871372)) +* OutputPicker shows stale result after 'Test up to here' ([#8390](https://github.com/windmill-labs/windmill/issues/8390)) ([2907084](https://github.com/windmill-labs/windmill/commit/2907084ca653fc5540bb04a409d2789ddaeec05b)) +* propagate enterprise feature to windmill-api-schedule ([#8391](https://github.com/windmill-labs/windmill/issues/8391)) ([50ef9e7](https://github.com/windmill-labs/windmill/commit/50ef9e79fcef8ee2cccd789b5eb1aacf5647365f)) +* set nsjail time_limit from job timeout so configured defaults are respected ([#8389](https://github.com/windmill-labs/windmill/issues/8389)) ([65a92d9](https://github.com/windmill-labs/windmill/commit/65a92d98994dbe4ae90a5e554e55b3ab44463f86)) +* soft error on AI agent max iterations + rename retries tab to error handling ([#8366](https://github.com/windmill-labs/windmill/issues/8366)) ([1a1e8a1](https://github.com/windmill-labs/windmill/commit/1a1e8a164cccbfcc663b963cb062af9208ff51be)) +* use bookworm-based php image to fix glibc 2.38 incompatibility ([#8381](https://github.com/windmill-labs/windmill/issues/8381)) ([68fd900](https://github.com/windmill-labs/windmill/commit/68fd900076ecf8b20f6622cd5794f1b52c0f5cab)) + ## [1.657.2](https://github.com/windmill-labs/windmill/compare/v1.657.1...v1.657.2) (2026-03-15) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a841b0c356..1343e441ad 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -9247,9 +9247,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -9257,9 +9257,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -15741,7 +15741,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-nats", @@ -15808,7 +15808,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15821,7 +15821,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "argon2", @@ -15962,7 +15962,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15985,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15998,7 +15998,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16024,7 +16024,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.657.2" +version = "1.658.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16034,7 +16034,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16051,7 +16051,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16074,7 +16074,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16097,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16113,7 +16113,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16133,7 +16133,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16153,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16167,7 +16167,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-nats", @@ -16195,7 +16195,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16220,7 +16220,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16238,7 +16238,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16259,7 +16259,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16279,7 +16279,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16309,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16336,7 +16336,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.657.2" +version = "1.658.0" dependencies = [ "lazy_static", "serde", @@ -16348,7 +16348,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.657.2" +version = "1.658.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16371,7 +16371,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16385,7 +16385,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16416,7 +16416,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.657.2" +version = "1.658.0" dependencies = [ "chrono", "lazy_static", @@ -16430,7 +16430,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16449,7 +16449,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.657.2" +version = "1.658.0" dependencies = [ "aes-gcm", "anyhow", @@ -16548,7 +16548,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.657.2" +version = "1.658.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16567,7 +16567,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.657.2" +version = "1.658.0" dependencies = [ "regex", "serde", @@ -16582,7 +16582,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16606,7 +16606,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "futures", @@ -16623,7 +16623,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.657.2" +version = "1.658.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16639,7 +16639,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -16660,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -16691,7 +16691,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-oauth2", @@ -16715,7 +16715,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-stream", @@ -16749,7 +16749,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "futures", @@ -16767,7 +16767,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.657.2" +version = "1.658.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16776,7 +16776,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16788,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "serde_json", @@ -16800,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "gosyn", @@ -16812,7 +16812,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16824,7 +16824,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "serde_json", @@ -16836,7 +16836,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "nu-parser", @@ -16847,7 +16847,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16858,7 +16858,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16870,7 +16870,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16881,7 +16881,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-recursion", @@ -16905,7 +16905,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16919,7 +16919,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16936,7 +16936,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16950,7 +16950,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "serde", @@ -16962,7 +16962,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16980,7 +16980,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16996,7 +16996,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17012,7 +17012,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "serde", @@ -17023,7 +17023,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-recursion", @@ -17060,7 +17060,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "const_format", @@ -17098,7 +17098,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.657.2" +version = "1.658.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17109,7 +17109,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-recursion", @@ -17138,7 +17138,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17161,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17194,7 +17194,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17214,7 +17214,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17248,7 +17248,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17283,7 +17283,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17306,7 +17306,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17330,7 +17330,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-nats", @@ -17354,7 +17354,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17389,7 +17389,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17417,7 +17417,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17440,7 +17440,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17458,7 +17458,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-once-cell", @@ -17565,7 +17565,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.657.2" +version = "1.658.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f787e0d73f..2cabf47d41 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.657.2" +version = "1.658.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.657.2" +version = "1.658.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3ccd769c58..7158f315ec 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.657.2 + version: 1.658.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 50fe0a3281..6e6459b2c9 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.657.2"; +export const VERSION = "v1.658.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 24cc45a1e9..01d4040f4f 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -68,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.657.2"; +export const VERSION = "1.658.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f20b5559b5..010d797e34 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.657.2", + "version": "1.658.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.657.2", + "version": "1.658.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index e14c1ae9c9..2a14c5f01f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.657.2", + "version": "1.658.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 3b6d1a73f6..6cace22096 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.657.2" +wmill = ">=1.658.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 4e9490f2d5..0e9a0a8214 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.657.2 + version: 1.658.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index e685867f17..d3f906eccc 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.657.2' + ModuleVersion = '1.658.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 795eba8bca..b0ce39509a 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.657.2" +version = "1.658.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 6efa859cfa..96450d49f6 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.657.2", + "version": "1.658.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 843e33fed9..2f3d36abf2 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.657.2", + "version": "1.658.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 6b7047454e..d460528676 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.657.2 +1.658.0 From 0b65c3d8fa848edb17cf23344648c7f06579f706 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 16 Mar 2026 17:57:38 +0100 Subject: [PATCH 66/76] index EE files in wm-ts-nav code navigator (#8400) EE files (*_ee.rs, *_ee.ts, *_ee.svelte) are symlinks from windmill-ee-private that are gitignored. The walker skipped them because it respects .gitignore. This adds a separate recursive scan for _ee files and merges them into the index. Also fixes outline resolving symlinks via canonicalize, causing path mismatches. Co-authored-by: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 ++ wm-ts-nav/src/indexer.rs | 40 ++++++++++++++++++++++++++++++++++++++-- wm-ts-nav/src/main.rs | 9 ++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1e768c2142..0f01e67c57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,8 @@ let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props() - `refs "X" --caller` instead of reading files to find which function contains each reference - `callers "X"` / `callees "X"` for call-graph questions +EE files (`*_ee.rs`, `*_ee.ts`, `*_ee.svelte`) are indexed — you can `outline`, `def`, `body`, `refs` etc. on them just like regular files. + ```bash NAV="sh wm-ts-nav/nav" # Use --root backend for Rust, --root frontend/src for TS/Svelte diff --git a/wm-ts-nav/src/indexer.rs b/wm-ts-nav/src/indexer.rs index ef69fbd1f4..7b1da49a74 100644 --- a/wm-ts-nav/src/indexer.rs +++ b/wm-ts-nav/src/indexer.rs @@ -2,7 +2,7 @@ use anyhow::Result; use ignore::WalkBuilder; use rayon::prelude::*; use std::collections::HashSet; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::db::{self, Db}; use crate::parser::{self, Lang}; @@ -14,14 +14,40 @@ pub struct IndexStats { pub files_unchanged: usize, } +/// Find gitignored *_ee files (EE symlinks from windmill-ee-private). +fn find_ee_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_str().unwrap_or(""); + // path.is_dir() follows symlinks + if path.is_dir() { + if !matches!(name_str, "target" | "node_modules" | ".git") { + find_ee_files(&path, out); + } + } else if Lang::from_path(&path).is_some() + && (name_str.ends_with("_ee.rs") + || name_str.ends_with("_ee.ts") + || name_str.ends_with("_ee.svelte")) + && path.exists() // follows symlink, checks target exists + { + out.push(path); + } + } +} + /// Incrementally update the index for the given root directory. /// Only re-parses files whose mtime has changed since last index. pub fn update_index(db: &Db, root: &Path) -> Result { // Collect all supported files using `ignore` crate (respects .gitignore) - let files: Vec<_> = WalkBuilder::new(root) + let mut files: Vec<_> = WalkBuilder::new(root) .hidden(true) .git_ignore(true) .git_global(false) + .follow_links(true) .build() .filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) @@ -29,6 +55,16 @@ pub fn update_index(db: &Db, root: &Path) -> Result { .map(|e| e.into_path()) .collect(); + // Also include gitignored *_ee files (EE symlinks from windmill-ee-private) + let file_set: HashSet<_> = files.iter().cloned().collect(); + let mut ee_files = Vec::new(); + find_ee_files(root, &mut ee_files); + for f in ee_files { + if !file_set.contains(&f) { + files.push(f); + } + } + let disk_paths: HashSet = files .iter() .map(|p| p.to_string_lossy().to_string()) diff --git a/wm-ts-nav/src/main.rs b/wm-ts-nav/src/main.rs index 8ae8c11f5b..a498b77c09 100644 --- a/wm-ts-nav/src/main.rs +++ b/wm-ts-nav/src/main.rs @@ -94,6 +94,13 @@ enum Command { }, } +/// Get absolute path without resolving symlinks (canonicalize the parent, keep the filename). +fn absolute_no_symlink(p: &std::path::Path) -> Result { + let parent = p.parent().unwrap_or(std::path::Path::new(".")); + let parent = std::fs::canonicalize(parent)?; + Ok(parent.join(p.file_name().unwrap_or_default())) +} + fn main() -> Result<()> { let cli = Cli::parse(); let root = cli @@ -114,7 +121,7 @@ fn main() -> Result<()> { ); } Command::Outline { file } => { - let file = std::fs::canonicalize(&file)?; + let file = absolute_no_symlink(&file)?; let symbols = db.file_symbols(&file.to_string_lossy())?; if symbols.is_empty() { println!("No symbols found"); From 5751e9b26b3dfab129ef8ec2c5b2db5f8855ea6b Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:12:29 +0100 Subject: [PATCH 67/76] chore: return structured error object on AI agent max iterations (#8403) * fix: return structured error object on AI agent max iterations Co-Authored-By: Claude Opus 4.6 * refactor: avoid double serialization in max iterations error Co-Authored-By: Claude Opus 4.6 * fix: replace unwrap with ? for to_raw_value in max iterations error Co-Authored-By: Claude Opus 4.6 * fix: add step_id to max iterations error for consistency with SerializedError Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- backend/windmill-worker/src/ai_executor.rs | 30 +++++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 001b930c6b..710daacef2 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -1038,13 +1038,29 @@ pub async fn run_agent( if tool_calls.is_empty() { break; } else if i == max_iterations - 1 { - let partial_result = serde_json::to_string(&serde_json::json!({ - "messages": messages, - })).unwrap_or_default(); - return Err(Error::internal_err(format!( - "AI agent reached max iterations ({}), you can either increase max_iterations or enable the \"continue on error\" option from the advanced options of the step. Partial result:\n{}", - max_iterations, partial_result - ))); + #[derive(serde::Serialize)] + struct MaxIterError<'a> { + message: String, + name: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + step_id: Option<&'a str>, + result: MaxIterPartialResult<'a>, + } + #[derive(serde::Serialize)] + struct MaxIterPartialResult<'a> { + messages: &'a [OpenAIMessage], + } + return Err(Error::ExecutionRawError( + serde_json::value::to_raw_value(&MaxIterError { + message: format!( + "AI agent reached max iterations ({}), you can either increase max_iterations or enable the \"continue on error\" option from the advanced options of the step.", + max_iterations + ), + name: "ExecutionErr", + step_id: effective_flow_step_id, + result: MaxIterPartialResult { messages: &messages }, + })?, + )); } messages.push(OpenAIMessage { From de5b13b840f90e23df1871f80317fdcc2b98174d Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 17 Mar 2026 01:17:03 +0100 Subject: [PATCH 68/76] feat: add end_user_email claim to OIDC ID tokens (#8401) * feat: add end_user_email claim to OIDC ID tokens When a job is triggered by an app end user, the OIDC ID token now includes the end_user_email claim automatically. The claim is omitted for jobs without an end user (regular script/flow runs). Co-Authored-By: Claude Opus 4.6 (1M context) * update ee ref --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...629a3e94247c696ce1e222a0e830630141c13.json | 22 +++++++++++++++++++ backend/ee-repo-ref.txt | 2 +- backend/windmill-common/src/oidc_oss.rs | 2 ++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json diff --git a/backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json b/backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json new file mode 100644 index 0000000000..e006a18f4b --- /dev/null +++ b/backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT end_user_email FROM job_perms WHERE job_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "end_user_email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 01d198fe94..fd93c7df88 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -b2fde51087d0d0ee0223c15cafb4e8badddd2d13 +04689be519d724e437b64c2ec86d47b3270c9be8 diff --git a/backend/windmill-common/src/oidc_oss.rs b/backend/windmill-common/src/oidc_oss.rs index 0a42cee2ed..e212558377 100644 --- a/backend/windmill-common/src/oidc_oss.rs +++ b/backend/windmill-common/src/oidc_oss.rs @@ -60,6 +60,8 @@ pub struct JobClaim { pub username: String, pub email: String, pub workspace: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_user_email: Option, } #[cfg(not(feature = "private"))] From fd41cd12b444fb2439214fcd25536280e5baacb2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 00:49:21 +0000 Subject: [PATCH 69/76] fix: improve OOM killer observability for debugging pod-level kills (#8398) * fix: improve OOM killer observability for debugging pod-level kills Co-Authored-By: Claude Opus 4.6 (1M context) * fix: lower worker oom_score_adj to protect it from OOM killer Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review feedback on OOM observability Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/src/cgroups.rs | 57 +++++++++++++++++++-- backend/src/main.rs | 50 +++++++++++++++++- backend/windmill-worker/src/handle_child.rs | 27 ++++++---- 3 files changed, 118 insertions(+), 16 deletions(-) diff --git a/backend/src/cgroups.rs b/backend/src/cgroups.rs index c6120c3524..285b28dfe7 100644 --- a/backend/src/cgroups.rs +++ b/backend/src/cgroups.rs @@ -42,24 +42,73 @@ pub fn disable_oom_group() -> Result<(), CgroupError> { let oom_group_file = cgroup_path.join("memory.oom.group"); if !oom_group_file.exists() { + tracing::warn!( + "memory.oom.group not found at {:?} — cgroups v2 memory controller may not be enabled. \ + OOM killer may kill the entire pod instead of individual jobs", + oom_group_file + ); return Err(CgroupError::NotSupported); } let current = fs::read_to_string(&oom_group_file)?; if current.trim() == "0" { - tracing::info!("memory.oom.group already disabled"); + tracing::info!("memory.oom.group already disabled at {:?}", cgroup_path); return Ok(()); } + tracing::info!( + "memory.oom.group is currently '{}' at {:?}, attempting to disable", + current.trim(), + cgroup_path + ); + match fs::write(&oom_group_file, "0") { Ok(_) => { - tracing::info!("Disabled memory.oom.group at {:?}", cgroup_path); + // Verify the write took effect + match fs::read_to_string(&oom_group_file) { + Ok(val) if val.trim() == "0" => { + tracing::info!("Disabled memory.oom.group at {:?}", cgroup_path); + } + Ok(val) => { + tracing::error!( + "Wrote 0 to memory.oom.group but read back '{}' at {:?}. \ + OOM killer may kill the entire pod instead of individual jobs", + val.trim(), + cgroup_path + ); + return Err(CgroupError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "memory.oom.group write did not take effect, read back '{}'", + val.trim() + ), + ))); + } + Err(e) => { + tracing::warn!( + "Wrote 0 to memory.oom.group but could not verify at {:?}: {e}", + cgroup_path + ); + } + } Ok(()) } Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { - tracing::error!("Failed to disable memory.oom.group (need privileged mode)"); + tracing::error!( + "Failed to disable memory.oom.group at {:?} (permission denied). \ + The container needs SYS_RESOURCE capability or privileged mode. \ + OOM killer WILL kill the entire pod instead of individual jobs", + oom_group_file + ); Err(CgroupError::PermissionDenied) } - Err(e) => Err(CgroupError::Io(e)), + Err(e) => { + tracing::error!( + "Failed to disable memory.oom.group at {:?}: {e}. \ + OOM killer may kill the entire pod instead of individual jobs", + oom_group_file + ); + Err(CgroupError::Io(e)) + } } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 08d59c9dd9..aa9f16af89 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -870,7 +870,55 @@ async fn windmill_main() -> anyhow::Result<()> { if worker_mode { #[cfg(any(target_os = "linux"))] if let Err(e) = disable_oom_group() { - tracing::warn!("failed to disable oom group: {:?}", e); + tracing::warn!( + "Failed to disable cgroup OOM group kill: {e:?}. \ + When a job exceeds memory, the OOM killer will kill the entire pod \ + instead of just the offending job process" + ); + } + + // Lower the worker's oom_score_adj so the OOM killer strongly prefers killing + // job subprocesses (oom_score_adj=1000) over the worker itself. + // Kubernetes sets it high for burstable QoS (e.g. 937), leaving a tiny gap vs jobs. + // Requires CAP_SYS_RESOURCE to lower it; if missing, we just warn. + #[cfg(any(target_os = "linux"))] + match std::fs::read_to_string("/proc/self/oom_score_adj") { + Ok(current) => { + let current = current.trim().to_string(); + let current_val = match current.parse::() { + Ok(v) => v, + Err(e) => { + tracing::warn!("Could not parse oom_score_adj '{current}': {e}"); + 0 + } + }; + if current_val > 0 { + match std::fs::write("/proc/self/oom_score_adj", "0") { + Ok(_) => { + tracing::info!( + "Lowered worker oom_score_adj from {current} to 0 \ + (jobs get 1000, gap=1000)" + ); + } + Err(e) => { + tracing::warn!( + "Could not lower worker oom_score_adj from {current} to 0: {e}. \ + Gap to jobs is only {} — OOM killer may target the worker instead. \ + Add CAP_SYS_RESOURCE to the container to fix this", + 1000 - current_val + ); + } + } + } else { + tracing::info!( + "Worker oom_score_adj={current} (jobs get 1000, gap={})", + 1000 - current_val + ); + } + } + Err(e) => { + tracing::warn!("Could not read worker oom_score_adj: {e}"); + } } } diff --git a/backend/windmill-worker/src/handle_child.rs b/backend/windmill-worker/src/handle_child.rs index 205da6700a..06db11ab24 100644 --- a/backend/windmill-worker/src/handle_child.rs +++ b/backend/windmill-worker/src/handle_child.rs @@ -128,17 +128,22 @@ pub async fn handle_child( let pid = child.id(); #[cfg(target_os = "linux")] if let Some(pid) = pid { - //set the highest oom priority - if let Some(mut file) = File::create(format!("/proc/{pid}/oom_score_adj")) - .await - .map_err(|e| { - tracing::error!("Could not create oom_score_file to pid {pid}: {e:#}"); - e - }) - .ok() - { - let _ = file.write_all(b"1000").await; - let _ = file.sync_all().await; + //set the highest oom priority so OOM killer targets this job, not the worker + match File::create(format!("/proc/{pid}/oom_score_adj")).await { + Ok(mut file) => { + if let Err(e) = file.write_all(b"1000").await { + tracing::error!("Failed to write oom_score_adj for pid {pid}: {e:#}"); + } + if let Err(e) = file.sync_all().await { + tracing::warn!("Failed to sync oom_score_adj for pid {pid}: {e:#}"); + } + } + Err(e) => { + tracing::error!( + "Could not open /proc/{pid}/oom_score_adj: {e:#}. \ + OOM killer may target the worker instead of this job" + ); + } } } else { tracing::info!("could not get child pid"); From f2be625348ef308e9768d487e110abbd44d27855 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 01:15:38 +0000 Subject: [PATCH 70/76] feat: store hashed tokens instead of plaintext (#8217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: store hashed tokens in the token table instead of plaintext Co-Authored-By: Claude Opus 4.6 * fix: address review issues in token hash migration - Update all base.sql fixtures to include token_hash/token_prefix columns - Keep plaintext token for webhook tokens (needed for URL reconstruction) - Restore get_token_by_prefix to query DB for webhook tokens - Fix down migration to delete NULL-token rows before restoring NOT NULL - Update parser fixture standalone schema - Update EE dedicated_worker_ee.rs to use token_hash/token_prefix Co-Authored-By: Claude Opus 4.6 * fix: restore sqlx offline cache (only add new query files) Co-Authored-By: Claude Opus 4.6 * refactor: keep writing plaintext token column for backward compat Write to token column alongside token_hash until MIN_VERSION_SUPPORTS_TOKEN_HASH (1.649.0) is reached. This ensures older workers can still authenticate during rolling upgrades. Remove the separate UPDATE in new_webhook_token since create_token_internal now writes plaintext directly. Co-Authored-By: Claude Opus 4.6 * feat: branch on MIN_VERSION to write plaintext token or null Check MIN_VERSION_SUPPORTS_TOKEN_HASH at runtime: write plaintext to token column while old workers exist, switch to NULL once all workers are >= 1.649.0. Co-Authored-By: Claude Opus 4.6 * fix: set MIN_VERSION_SUPPORTS_TOKEN_HASH to 1.650.0 Co-Authored-By: Claude Opus 4.6 * fix: use token_hash for email lookup and expiry notifications Co-Authored-By: Claude Opus 4.6 * refactor: rotate webhook tokens instead of recovering plaintext from DB Co-Authored-By: Claude Opus 4.6 * refactor: use token_hash for native trigger token lookups and deletes Co-Authored-By: Claude Opus 4.6 * sqlx * refactor: drop webhook_token_prefix from native_trigger table Co-Authored-By: Claude Opus 4.5 * fix: backward compat for token rotation and make webhook_token_hash NOT NULL Co-Authored-By: Claude Opus 4.5 * fix: prevent panic on short superadmin secret token prefix Co-Authored-By: Claude Opus 4.5 * fix: prevent panic on short superadmin secret token prefix Replace all `token[0..TOKEN_PREFIX_LEN]` slicing with `token.get(..TOKEN_PREFIX_LEN).unwrap_or(token)` to prevent panics when a token shorter than 10 chars is provided (e.g. malformed Authorization header, short superadmin secret). Co-authored-by: hugocasa * fix: prevent panic on short token prefix slicing Replace all `token[0..TOKEN_PREFIX_LEN]` with safe `token.get(..TOKEN_PREFIX_LEN).unwrap_or(token)` to prevent panics on malformed tokens shorter than 10 characters. Co-authored-by: hugocasa Co-Authored-By: Claude Opus 4.6 * Revert "fix: prevent panic on short superadmin secret token prefix" This reverts commit 37ec2e5ad52a0552da694bf9ef991b4bc267561e. * revert: remove unnecessary defensive token prefix slicing Co-Authored-By: Claude Opus 4.6 * fix: add token_hash to end_user_email test fixture Co-Authored-By: Claude Opus 4.5 * test: add integration tests for token hash migration Co-Authored-By: Claude Opus 4.5 * fix: correct token_hash test assertions for cache and version Co-Authored-By: Claude Opus 4.5 * chore: add plaintext column removal reminder to test fixtures Co-Authored-By: Claude Opus 4.5 * fix: log count of orphaned triggers deleted during migration Co-Authored-By: Claude Opus 4.5 * fix: preserve orphaned triggers with error instead of deleting Co-Authored-By: Claude Opus 4.5 * fix: rename token_expiry_notification.token to token_hash and copy owner/expiration in rotate Co-Authored-By: Claude Opus 4.5 * fix: hash existing plaintext values before renaming token_expiry_notification column Co-Authored-By: Claude Opus 4.5 * fix: remove unnecessary length check in token_expiry_notification migration Co-Authored-By: Claude Opus 4.5 * update dates and version * updat ee ref + sqlx * improve mcp migration * fix: atomic token rotation with rollback on trigger update failure rotate_webhook_token now atomically inserts the new token and deletes the old one in a single transaction, preventing token leaks. Returns new_token_hash so callers can clean up the new token if their subsequent trigger update fails (which involves external HTTP calls and cannot be in the same DB transaction). - Handler: wraps post-rotation work; deletes new token on failure - Google renewal: deletes new token if service_config update fails - Tests updated to match new atomic semantics Co-Authored-By: Claude Opus 4.6 (1M context) * higher min version * fix: defer old token deletion to avoid breaking triggers on update failure rotate_webhook_token now keeps the old token alive and returns old_token_hash. Callers delete it only after the trigger row has been successfully updated. If the external service call or DB update fails, the trigger keeps working with the old token. Worst case: if the best-effort delete fails, the old token leaks as an extra DB row — harmless compared to breaking the trigger. Also update summarized_schema.txt for renamed columns. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 2d0823a471014e2bc2d898c63518323946b7474f This commit updates the EE repository reference after PR #437 was merged in windmill-ee-private. Previous ee-repo-ref: 7aef8b06cb6f54c2bc89dd57b70947deed72553c New ee-repo-ref: 2d0823a471014e2bc2d898c63518323946b7474f Automated by sync-ee-ref workflow. * fix: prevent panic on short tokens by using safe prefix extraction Add safe_token_prefix() helper that uses .get(..TOKEN_PREFIX_LEN).unwrap_or(token) instead of direct slice indexing, preventing panics when tokens are shorter than 10 characters (e.g., short superadmin secrets or malformed Bearer tokens). Co-authored-by: Ruben Fiszel Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: HugoCasa Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: hugocasa Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...39a0405d479e02ac79c2f4ec9a97636bd80d.json} | 7 +- ...85f937776a6b47cd99c648bb4d819d3cfe57.json} | 8 +- ...54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json | 17 - ...2749ef04e1a8c944c0fb7696e387d10370217.json | 16 + ...a182f3953150ade1756259b31a50e9308fce.json} | 6 +- ...408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json} | 8 +- ...b7e169d07463f67f1da79c8bc23c1079c185.json} | 8 +- ...1a47475dcf1a388c613a9e0154aef6582a23.json} | 4 +- ...2e1f4901b0382fca50704c9544cdec5f9352c.json | 21 + ...1b372e9d40dee29a10fb8fec89492fc501556.json | 20 + ...49b8082c0052d626bf67e08317e56ab9ad026.json | 58 +++ ...6f2e18cb911352551d0a34bf1933e558674e.json} | 4 +- ...4b936c3ffff44ae58f0709d05ba3ff534f79.json} | 4 +- ...aab2b35961f750600500e3736edcff1c191fe.json | 20 + ...697cd321b397422a7c689b85216bbb1fd525.json} | 8 +- ...e2e60e3183fa81a411622891caea6dc03fa90.json | 15 + ...27a85270c25c5a0f0d4d7670bd602bd3cebf.json} | 6 +- ...c84c3bb44adc490309954359845c06b6e607c.json | 18 - ...b25915f671e7c52426fc54b2fd533b90596e2.json | 20 + ...fa0e94550eb617612c2bba81b9ce150d355b5.json | 22 + ...578261fb9ebd92a81e1f2c8edaf93cece0819.json | 14 - ...3c0bcddc0943346f02315b962db3b13590d30.json | 22 + ...86a4f4ea8880cc5d0b511aa70b6fd50072c5.json} | 6 +- ...25bf2f3a35d90f7354c7d3aef8a3538a03a7.json} | 4 +- ...b994db0dc1728945f90fd23b707ee355d0472.json | 22 + ...2e1900d97721d931cea80a4fe240901b3604.json} | 4 +- ...d8e56f0c70dccd0edbc9667f550b31ab54c18.json | 12 - ...71f5a0ab04cc4ac9f236ffd056d3acdb79711.json | 23 + ...ebf080df3f76ac3e6e6373a89c8d46388125d.json | 18 - ...e955820f7b2761df6b38a6a6615b518188f9.json} | 4 +- ...c6c06aa6934b87200da14231f12f65149cd3.json} | 8 +- ...9b09a60d732099a2de9e001d2b40a8e919a9.json} | 7 +- ...3642a89b16701e71d624c6adacb652e231a1a.json | 22 + ...5c57375fdb528a30de49adbe5305fc237b2c5.json | 20 + ...675a2a05eaf30237e21359c52f31bb1bddc73.json | 22 + ...dc715d80b00c0e79b61b0057a3ae3886a85e.json} | 6 +- ...da8d6a3b425ea0590a66f1db6692dd2ddb437.json | 15 - ...702443285e4d1d11e5a0f4cd2a3d4add51bb.json} | 6 +- ...2c11ed88327cf033e1abfd7c0458b71f53da.json} | 6 +- ...24d43eaab99b9f1b7d55eff25b0225bb1504.json} | 4 +- ...dfdf104e3d77b0725c5355305d19f67359e90.json | 19 + ...30c0cf40a99edb4ae9fe3e9be485ab1077b3a.json | 19 - ...9945b6baeb1f1e9e36c9949b9d5fe1f78431.json} | 4 +- ...5209e1ff2c235df1e39ddc3dd21f60f66ef4.json} | 4 +- ...d19d914307a95d72bb0fb447e5b367d52f6a0.json | 20 - ...030140350134ed4f42478daba17655802f238.json | 15 + ...706d78a6f24cb0e614d7d81ba1b643805bf06.json | 22 - ...32caa6e6682620c90f38b29dd32b9a8fe633.json} | 8 +- backend/ee-repo-ref.txt | 2 +- .../20260316000000_token_hash.down.sql | 8 + .../20260316000000_token_hash.up.sql | 31 ++ ...20260316000001_token_hash_pk_swap.down.sql | 26 ++ .../20260316000001_token_hash_pk_swap.up.sql | 22 + ...6000002_native_trigger_token_hash.down.sql | 9 + ...316000002_native_trigger_token_hash.up.sql | 22 + ..._token_expiry_notification_column.down.sql | 5 + ...me_token_expiry_notification_column.up.sql | 5 + ...cp_oauth_rename_access_token_hash.down.sql | 4 + ..._mcp_oauth_rename_access_token_hash.up.sql | 6 + .../tests/fixtures/base.sql | 8 +- backend/src/main.rs | 2 +- backend/src/monitor.rs | 10 +- backend/summarized_schema.txt | 8 +- backend/tests/fixtures/base.sql | 8 +- backend/tests/fixtures/end_user_email.sql | 10 +- backend/tests/fixtures/permissions_test.sql | 12 +- .../tests/fixtures/preserve_on_behalf_of.sql | 16 +- backend/windmill-api-auth/src/auth.rs | 52 ++- backend/windmill-api-auth/src/lib.rs | 42 +- .../tests/fixtures/base.sql | 8 +- .../tests/fixtures/permissions_test.sql | 12 +- .../tests/permissions.rs | 113 ++++- .../tests/token_hash.rs | 399 ++++++++++++++++++ backend/windmill-api-users/src/users.rs | 74 +++- .../windmill-api-workspaces/src/workspaces.rs | 2 +- backend/windmill-api/src/mcp/oauth_server.rs | 63 ++- backend/windmill-api/src/workspaces_export.rs | 2 +- backend/windmill-common/src/auth.rs | 15 + backend/windmill-common/src/min_version.rs | 1 + .../windmill-common/tests/fixtures/base.sql | 6 +- .../windmill-common/tests/notify_events.rs | 335 +++++++++++---- .../src/google/external.rs | 40 +- .../windmill-native-triggers/src/handler.rs | 87 ++-- backend/windmill-native-triggers/src/lib.rs | 138 ++++-- backend/windmill-native-triggers/src/sync.rs | 1 + .../src/workspace_integrations.rs | 12 +- .../windmill-queue/tests/fixtures/base.sql | 6 +- 87 files changed, 1725 insertions(+), 543 deletions(-) rename backend/.sqlx/{query-3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff.json => query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json} (54%) rename backend/.sqlx/{query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json => query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json} (75%) delete mode 100644 backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json create mode 100644 backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json rename backend/.sqlx/{query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json => query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json} (80%) rename backend/.sqlx/{query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json => query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json} (68%) rename backend/.sqlx/{query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json => query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json} (61%) rename backend/.sqlx/{query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json => query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json} (55%) create mode 100644 backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json create mode 100644 backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json create mode 100644 backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json rename backend/.sqlx/{query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json => query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json} (63%) rename backend/.sqlx/{query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json => query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json} (54%) create mode 100644 backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json rename backend/.sqlx/{query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json => query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json} (61%) create mode 100644 backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json rename backend/.sqlx/{query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json => query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json} (65%) delete mode 100644 backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json create mode 100644 backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json create mode 100644 backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json delete mode 100644 backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json create mode 100644 backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json rename backend/.sqlx/{query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json => query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json} (71%) rename backend/.sqlx/{query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json => query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json} (62%) create mode 100644 backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json rename backend/.sqlx/{query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json => query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json} (57%) delete mode 100644 backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json create mode 100644 backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json delete mode 100644 backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json rename backend/.sqlx/{query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json => query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json} (70%) rename backend/.sqlx/{query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json => query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json} (62%) rename backend/.sqlx/{query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json => query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json} (57%) create mode 100644 backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json create mode 100644 backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json create mode 100644 backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json rename backend/.sqlx/{query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json => query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json} (64%) delete mode 100644 backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json rename backend/.sqlx/{query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json => query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json} (82%) rename backend/.sqlx/{query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json => query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json} (86%) rename backend/.sqlx/{query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json => query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json} (66%) create mode 100644 backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json delete mode 100644 backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json rename backend/.sqlx/{query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json => query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json} (65%) rename backend/.sqlx/{query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json => query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json} (57%) delete mode 100644 backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json create mode 100644 backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json delete mode 100644 backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json rename backend/.sqlx/{query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json => query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json} (72%) create mode 100644 backend/migrations/20260316000000_token_hash.down.sql create mode 100644 backend/migrations/20260316000000_token_hash.up.sql create mode 100644 backend/migrations/20260316000001_token_hash_pk_swap.down.sql create mode 100644 backend/migrations/20260316000001_token_hash_pk_swap.up.sql create mode 100644 backend/migrations/20260316000002_native_trigger_token_hash.down.sql create mode 100644 backend/migrations/20260316000002_native_trigger_token_hash.up.sql create mode 100644 backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql create mode 100644 backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql create mode 100644 backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql create mode 100644 backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql create mode 100644 backend/windmill-api-integration-tests/tests/token_hash.rs diff --git a/backend/.sqlx/query-3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff.json b/backend/.sqlx/query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json similarity index 54% rename from backend/.sqlx/query-3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff.json rename to backend/.sqlx/query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json index edabfc0be8..734f76ba0a 100644 --- a/backend/.sqlx/query-3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff.json +++ b/backend/.sqlx/query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json @@ -1,17 +1,16 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM variable WHERE path = $1 AND workspace_id = $2 RETURNING path", + "query": "SELECT token_hash FROM token WHERE token_hash = $1", "describe": { "columns": [ { "ordinal": 0, - "name": "path", + "name": "token_hash", "type_info": "Varchar" } ], "parameters": { "Left": [ - "Text", "Text" ] }, @@ -19,5 +18,5 @@ false ] }, - "hash": "3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff" + "hash": "0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d" } diff --git a/backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json b/backend/.sqlx/query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json similarity index 75% rename from backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json rename to backend/.sqlx/query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json index 9085383617..a59afbf3ef 100644 --- a/backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json +++ b/backend/.sqlx/query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE expiration <= now()\n RETURNING substring(token for 10) as token_prefix, label, email, workspace_id", + "query": "DELETE FROM token WHERE expiration <= now()\n RETURNING token_prefix, label, email, workspace_id", "describe": { "columns": [ { "ordinal": 0, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 1, @@ -28,11 +28,11 @@ "Left": [] }, "nullable": [ - null, + false, true, true, true ] }, - "hash": "bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f" + "hash": "104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57" } diff --git a/backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json b/backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json deleted file mode 100644 index 5552400f28..0000000000 --- a/backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, label, super_admin, email)\n VALUES ($1, $2, $3, $4)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Bool", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d" -} diff --git a/backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json b/backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json new file mode 100644 index 0000000000..85eaed8eda --- /dev/null +++ b/backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)\n VALUES ($1, $2, $3, 'test@windmill.dev', 'webhook-test', false)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217" +} diff --git a/backend/.sqlx/query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json b/backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json similarity index 80% rename from backend/.sqlx/query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json rename to backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json index 536175599d..492fffe8be 100644 --- a/backend/.sqlx/query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json +++ b/backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_prefix,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ", + "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 5, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" }, { @@ -95,5 +95,5 @@ false ] }, - "hash": "ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c" + "hash": "1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce" } diff --git a/backend/.sqlx/query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json b/backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json similarity index 68% rename from backend/.sqlx/query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json rename to backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json index 191b010aec..ae055e1b5f 100644 --- a/backend/.sqlx/query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json +++ b/backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -43,12 +43,12 @@ }, "nullable": [ true, - null, + false, true, false, false, true ] }, - "hash": "8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513" + "hash": "1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb" } diff --git a/backend/.sqlx/query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json b/backend/.sqlx/query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json similarity index 61% rename from backend/.sqlx/query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json rename to backend/.sqlx/query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json index 3e7972dbd7..e1cdb6416d 100644 --- a/backend/.sqlx/query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json +++ b/backend/.sqlx/query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT label,\n concat(substring(token for 10)) AS token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:flows:' || $2]::text[]\n OR scopes @> ARRAY['run:flow/' || $2]::text[]\n )\n ", + "query": "\n SELECT label,\n token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:scripts:' || $2]::text[]\n OR scopes @> ARRAY['run:script/' || $2]::text[]\n )\n ", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -47,7 +47,7 @@ }, "nullable": [ true, - null, + false, true, false, false, @@ -55,5 +55,5 @@ true ] }, - "hash": "6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163" + "hash": "207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185" } diff --git a/backend/.sqlx/query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json b/backend/.sqlx/query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json similarity index 55% rename from backend/.sqlx/query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json rename to backend/.sqlx/query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json index b58e3bf8d8..54a7a25b68 100644 --- a/backend/.sqlx/query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json +++ b/backend/.sqlx/query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH email_lookup AS (\n SELECT email FROM token WHERE token = $1\n )\n DELETE FROM token\n WHERE email = (SELECT email FROM email_lookup) AND label = 'session'\n RETURNING email", + "query": "WITH email_lookup AS (\n SELECT email FROM token WHERE token_hash = $1\n )\n DELETE FROM token\n WHERE email = (SELECT email FROM email_lookup) AND label = 'session'\n RETURNING email", "describe": { "columns": [ { @@ -18,5 +18,5 @@ true ] }, - "hash": "1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf" + "hash": "215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23" } diff --git a/backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json b/backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json new file mode 100644 index 0000000000..8595b12ec6 --- /dev/null +++ b/backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id)\n SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar\n WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "TextArray", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c" +} diff --git a/backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json b/backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json new file mode 100644 index 0000000000..f2acda445d --- /dev/null +++ b/backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT encode(sha256('SECRET_TOKEN'::bytea), 'hex') AS hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556" +} diff --git a/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json b/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json new file mode 100644 index 0000000000..2b5b68dfae --- /dev/null +++ b/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 3, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "owner", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "expiration", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026" +} diff --git a/backend/.sqlx/query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json b/backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json similarity index 63% rename from backend/.sqlx/query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json rename to backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json index 1199e9441f..706fa0c9ea 100644 --- a/backend/.sqlx/query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json +++ b/backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_prefix = $3, service_config = $4, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ", + "query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ", "describe": { "columns": [], "parameters": { @@ -26,5 +26,5 @@ }, "nullable": [] }, - "hash": "27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b" + "hash": "40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e" } diff --git a/backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json b/backend/.sqlx/query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json similarity index 54% rename from backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json rename to backend/.sqlx/query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json index 3d63bbcfbf..8fc87ce587 100644 --- a/backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json +++ b/backend/.sqlx/query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE token = $1", + "query": "DELETE FROM token WHERE token_hash = $1", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a" + "hash": "4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79" } diff --git a/backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json b/backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json new file mode 100644 index 0000000000..10c513f276 --- /dev/null +++ b/backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token_hash FROM token WHERE email = 'test@windmill.dev' AND label = 'test token'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_hash", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe" +} diff --git a/backend/.sqlx/query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json b/backend/.sqlx/query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json similarity index 61% rename from backend/.sqlx/query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json rename to backend/.sqlx/query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json index 251fce2637..5cf5699f2a 100644 --- a/backend/.sqlx/query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json +++ b/backend/.sqlx/query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT label,\n concat(substring(token for 10)) AS token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:scripts:' || $2]::text[]\n OR scopes @> ARRAY['run:script/' || $2]::text[]\n )\n ", + "query": "\n SELECT label,\n token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:flows:' || $2]::text[]\n OR scopes @> ARRAY['run:flow/' || $2]::text[]\n )\n ", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -47,7 +47,7 @@ }, "nullable": [ true, - null, + false, true, false, false, @@ -55,5 +55,5 @@ true ] }, - "hash": "29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1" + "hash": "4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525" } diff --git a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json new file mode 100644 index 0000000000..27d46b27ed --- /dev/null +++ b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id)\n VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90" +} diff --git a/backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json b/backend/.sqlx/query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json similarity index 65% rename from backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json rename to backend/.sqlx/query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json index da0ce60709..50a30bd742 100644 --- a/backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json +++ b/backend/.sqlx/query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json @@ -1,11 +1,11 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token", + "query": "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token_prefix", "describe": { "columns": [ { "ordinal": 0, - "name": "token", + "name": "token_prefix", "type_info": "Varchar" } ], @@ -18,5 +18,5 @@ false ] }, - "hash": "2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91" + "hash": "52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf" } diff --git a/backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json b/backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json deleted file mode 100644 index 39355ffc0c..0000000000 --- a/backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c" -} diff --git a/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json b/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json new file mode 100644 index 0000000000..de1b71a1e6 --- /dev/null +++ b/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2" +} diff --git a/backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json b/backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json new file mode 100644 index 0000000000..cc515a4e8f --- /dev/null +++ b/backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5" +} diff --git a/backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json b/backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json deleted file mode 100644 index f1346579a2..0000000000 --- a/backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM token\n WHERE token LIKE concat($1::text, '%')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819" -} diff --git a/backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json b/backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json new file mode 100644 index 0000000000..25bbd4f835 --- /dev/null +++ b/backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30" +} diff --git a/backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json b/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json similarity index 71% rename from backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json rename to backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json index d6240c6b3a..bf734e5861 100644 --- a/backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json +++ b/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT external_id, webhook_token_prefix FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + "query": "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", "describe": { "columns": [ { @@ -10,7 +10,7 @@ }, { "ordinal": 1, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" } ], @@ -35,5 +35,5 @@ false ] }, - "hash": "83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75" + "hash": "6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5" } diff --git a/backend/.sqlx/query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json b/backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json similarity index 62% rename from backend/.sqlx/query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json rename to backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json index d3f1c39c7a..69af249a3f 100644 --- a/backend/.sqlx/query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json +++ b/backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_prefix,\n service_config\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_prefix = $6, service_config = $7, error = NULL, updated_at = NOW()\n ", + "query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW()\n ", "describe": { "columns": [], "parameters": { @@ -26,5 +26,5 @@ }, "nullable": [] }, - "hash": "023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75" + "hash": "6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7" } diff --git a/backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json b/backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json new file mode 100644 index 0000000000..b3f0d62ca8 --- /dev/null +++ b/backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE token_hash = $1 RETURNING email", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472" +} diff --git a/backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json b/backend/.sqlx/query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json similarity index 57% rename from backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json rename to backend/.sqlx/query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json index 4843d959c1..fef6764ba9 100644 --- a/backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json +++ b/backend/.sqlx/query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT true FROM token WHERE token = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", + "query": "SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", "describe": { "columns": [ { @@ -19,5 +19,5 @@ null ] }, - "hash": "bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0" + "hash": "8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604" } diff --git a/backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json b/backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json deleted file mode 100644 index b03708c813..0000000000 --- a/backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token (token, email, label, super_admin, owner, workspace_id)\n VALUES ('CHARLIE_TOKEN_NEW', 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18" -} diff --git a/backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json b/backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json new file mode 100644 index 0000000000..f96090bad5 --- /dev/null +++ b/backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes, workspace_id, owner, expiration)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "TextArray", + "Varchar", + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711" +} diff --git a/backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json b/backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json deleted file mode 100644 index 90a2f78d2c..0000000000 --- a/backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Text", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d" -} diff --git a/backend/.sqlx/query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json b/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json similarity index 70% rename from backend/.sqlx/query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json rename to backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json index a3d8c2502f..c9faa982ba 100644 --- a/backend/.sqlx/query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json +++ b/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE token SET last_used_at = now() WHERE\n token = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label", + "query": "UPDATE token SET last_used_at = now() WHERE\n token_hash = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label", "describe": { "columns": [ { @@ -43,5 +43,5 @@ true ] }, - "hash": "c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3" + "hash": "93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9" } diff --git a/backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json b/backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json similarity index 62% rename from backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json rename to backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json index 015aa7b05a..d54260b4f7 100644 --- a/backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json +++ b/backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token = t.token\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id", + "query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id", "describe": { "columns": [ { "ordinal": 0, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 1, @@ -28,11 +28,11 @@ "Left": [] }, "nullable": [ - null, + false, true, true, true ] }, - "hash": "d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6" + "hash": "94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3" } diff --git a/backend/.sqlx/query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json b/backend/.sqlx/query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json similarity index 57% rename from backend/.sqlx/query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json rename to backend/.sqlx/query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json index afd0f503bf..a8fbb473d8 100644 --- a/backend/.sqlx/query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json +++ b/backend/.sqlx/query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE native_trigger\n SET service_config = $1, updated_at = NOW()\n WHERE\n workspace_id = $2\n AND service_name = $3\n AND external_id = $4\n ", + "query": "\n UPDATE native_trigger\n SET service_config = $1,\n webhook_token_hash = COALESCE($5, webhook_token_hash),\n updated_at = NOW()\n WHERE\n workspace_id = $2\n AND service_name = $3\n AND external_id = $4\n ", "describe": { "columns": [], "parameters": { @@ -18,10 +18,11 @@ } } }, - "Text" + "Text", + "Varchar" ] }, "nullable": [] }, - "hash": "2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1" + "hash": "95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9" } diff --git a/backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json b/backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json new file mode 100644 index 0000000000..959438f582 --- /dev/null +++ b/backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a" +} diff --git a/backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json b/backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json new file mode 100644 index 0000000000..69cd713502 --- /dev/null +++ b/backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5, $6, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5" +} diff --git a/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json b/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json new file mode 100644 index 0000000000..163dc2285b --- /dev/null +++ b/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9\n WHERE $9::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $9 AND deleted = true\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Bool", + "TextArray", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73" +} diff --git a/backend/.sqlx/query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json b/backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json similarity index 64% rename from backend/.sqlx/query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json rename to backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json index 16ccdd10e9..660c855622 100644 --- a/backend/.sqlx/query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json +++ b/backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_prefix,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ", + "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 5, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" }, { @@ -97,5 +97,5 @@ false ] }, - "hash": "ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee" + "hash": "a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e" } diff --git a/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json b/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json deleted file mode 100644 index af35d619fa..0000000000 --- a/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437" -} diff --git a/backend/.sqlx/query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json b/backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json similarity index 82% rename from backend/.sqlx/query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json rename to backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json index e6ef13f492..a90b2b8398 100644 --- a/backend/.sqlx/query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json +++ b/backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_prefix,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", + "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 5, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" }, { @@ -94,5 +94,5 @@ false ] }, - "hash": "b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d" + "hash": "bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb" } diff --git a/backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json b/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json similarity index 86% rename from backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json rename to backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json index 63478d0e66..ee946012b2 100644 --- a/backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json +++ b/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE mcp_oauth_refresh_token\n SET used_at = now()\n WHERE refresh_token = $1\n AND client_id = $2\n AND used_at IS NULL\n AND NOT revoked\n AND expires_at > now()\n RETURNING id, refresh_token, access_token, client_id, user_email, workspace_id,\n scopes, token_family, created_at, expires_at, used_at, revoked", + "query": "UPDATE mcp_oauth_refresh_token\n SET used_at = now()\n WHERE refresh_token = $1\n AND client_id = $2\n AND used_at IS NULL\n AND NOT revoked\n AND expires_at > now()\n RETURNING id, refresh_token, access_token_hash, client_id, user_email, workspace_id,\n scopes, token_family, created_at, expires_at, used_at, revoked", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "access_token", + "name": "access_token_hash", "type_info": "Varchar" }, { @@ -85,5 +85,5 @@ false ] }, - "hash": "5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd" + "hash": "c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da" } diff --git a/backend/.sqlx/query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json b/backend/.sqlx/query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json similarity index 66% rename from backend/.sqlx/query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json rename to backend/.sqlx/query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json index e68f25d6ab..86175b0dc4 100644 --- a/backend/.sqlx/query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json +++ b/backend/.sqlx/query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT job FROM token WHERE token = $1", + "query": "SELECT job FROM token WHERE token_hash = $1", "describe": { "columns": [ { @@ -18,5 +18,5 @@ true ] }, - "hash": "55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b" + "hash": "ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504" } diff --git a/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json b/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json new file mode 100644 index 0000000000..e778c17bf6 --- /dev/null +++ b/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90" +} diff --git a/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json b/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json deleted file mode 100644 index 73ffec7e48..0000000000 --- a/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token (token, email, label, expiration, scopes, workspace_id)\n SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar\n WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Text", - "TextArray", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a" -} diff --git a/backend/.sqlx/query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json b/backend/.sqlx/query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json similarity index 65% rename from backend/.sqlx/query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json rename to backend/.sqlx/query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json index 34ff650daf..eadb48cbb5 100644 --- a/backend/.sqlx/query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json +++ b/backend/.sqlx/query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE token = $1 RETURNING email", + "query": "SELECT email FROM token WHERE token = $1", "describe": { "columns": [ { @@ -18,5 +18,5 @@ true ] }, - "hash": "97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c" + "hash": "db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431" } diff --git a/backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json b/backend/.sqlx/query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json similarity index 57% rename from backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json rename to backend/.sqlx/query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json index 32e0372293..37055880ae 100644 --- a/backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json +++ b/backend/.sqlx/query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO mcp_oauth_refresh_token\n (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", + "query": "INSERT INTO mcp_oauth_refresh_token\n (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", "describe": { "columns": [], "parameters": { @@ -17,5 +17,5 @@ }, "nullable": [] }, - "hash": "2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8" + "hash": "dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4" } diff --git a/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json b/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json deleted file mode 100644 index 14757923d1..0000000000 --- a/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, email, label, expiration, super_admin, scopes, workspace_id)\n SELECT $1, $2, $3, $4, $5, $6, $7\n WHERE $7::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $7 AND deleted = true\n )", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Bool", - "TextArray", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0" -} diff --git a/backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json b/backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json new file mode 100644 index 0000000000..f4e8f0106c --- /dev/null +++ b/backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238" +} diff --git a/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json b/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json deleted file mode 100644 index c96961eac4..0000000000 --- a/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT token as \"token!\"\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token!", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06" -} diff --git a/backend/.sqlx/query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json b/backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json similarity index 72% rename from backend/.sqlx/query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json rename to backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json index 3f0749f7bf..657c660228 100644 --- a/backend/.sqlx/query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json +++ b/backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -43,12 +43,12 @@ }, "nullable": [ true, - null, + false, true, false, false, true ] }, - "hash": "3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905" + "hash": "ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index fd93c7df88..4615ab8810 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -04689be519d724e437b64c2ec86d47b3270c9be8 +2d0823a471014e2bc2d898c63518323946b7474f diff --git a/backend/migrations/20260316000000_token_hash.down.sql b/backend/migrations/20260316000000_token_hash.down.sql new file mode 100644 index 0000000000..02725da65d --- /dev/null +++ b/backend/migrations/20260316000000_token_hash.down.sql @@ -0,0 +1,8 @@ +-- Reverse of step 1: drop indexes and columns + +DROP INDEX IF EXISTS idx_token_plaintext; +DROP INDEX IF EXISTS idx_token_prefix; +DROP INDEX IF EXISTS token_hash_unique; + +ALTER TABLE token DROP COLUMN token_hash; +ALTER TABLE token DROP COLUMN token_prefix; diff --git a/backend/migrations/20260316000000_token_hash.up.sql b/backend/migrations/20260316000000_token_hash.up.sql new file mode 100644 index 0000000000..c0c3bb7375 --- /dev/null +++ b/backend/migrations/20260316000000_token_hash.up.sql @@ -0,0 +1,31 @@ +-- Step 1: Add columns, backfill, build indexes. +-- This migration does the heavy work but avoids ACCESS EXCLUSIVE during index build +-- by creating the unique index first, then using it for the PK swap in the next migration. + +-- Add new columns (instant metadata change) +ALTER TABLE token ADD COLUMN token_hash VARCHAR(64); +ALTER TABLE token ADD COLUMN token_prefix VARCHAR(10); + +-- Backfill existing tokens using built-in sha256() (no extension needed). +-- Takes ROW EXCLUSIVE lock — concurrent reads and non-token writes proceed normally. +UPDATE token +SET token_hash = encode(sha256(token::bytea), 'hex'), + token_prefix = substring(token for 10) +WHERE token_hash IS NULL; + +-- Mark NOT NULL (instant on PG 12+ when all rows already satisfy the constraint) +ALTER TABLE token ALTER COLUMN token_hash SET NOT NULL; +ALTER TABLE token ALTER COLUMN token_prefix SET NOT NULL; + +-- Build the unique index that the next migration will promote to PK. +-- Takes SHARE lock (reads OK, writes wait) but only for the duration of the build, +-- which is fast since token tables are typically small. +CREATE UNIQUE INDEX token_hash_unique ON token (token_hash); + +-- Index on prefix for deletion/listing +CREATE INDEX idx_token_prefix ON token (token_prefix); + +-- Keep old workers fast during rolling upgrades: they query WHERE token = $1 +-- after the PK swap drops the old primary key index on token. +-- Can be dropped once all workers are past MIN_VERSION_SUPPORTS_TOKEN_HASH. +CREATE INDEX idx_token_plaintext ON token (token) WHERE token IS NOT NULL; diff --git a/backend/migrations/20260316000001_token_hash_pk_swap.down.sql b/backend/migrations/20260316000001_token_hash_pk_swap.down.sql new file mode 100644 index 0000000000..ab938e3422 --- /dev/null +++ b/backend/migrations/20260316000001_token_hash_pk_swap.down.sql @@ -0,0 +1,26 @@ +-- Reverse step 2: restore old PK and trigger + +-- Restore the original trigger +CREATE OR REPLACE FUNCTION notify_token_invalidation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.label = 'session' AND OLD.email IS NOT NULL THEN + INSERT INTO notify_event (channel, payload) + VALUES ('notify_token_invalidation', OLD.token); + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Delete tokens created after migration that have no plaintext (cannot be restored) +DELETE FROM token WHERE token IS NULL; + +-- Make token NOT NULL again +ALTER TABLE token ALTER COLUMN token SET NOT NULL; + +-- Swap PK back: drop token_hash PK, restore token PK +ALTER TABLE token DROP CONSTRAINT token_pkey; +ALTER TABLE token ADD PRIMARY KEY (token); + +-- Re-create the unique index on token_hash (was consumed by ADD CONSTRAINT ... USING INDEX) +CREATE UNIQUE INDEX token_hash_unique ON token (token_hash); diff --git a/backend/migrations/20260316000001_token_hash_pk_swap.up.sql b/backend/migrations/20260316000001_token_hash_pk_swap.up.sql new file mode 100644 index 0000000000..38e93fb3ba --- /dev/null +++ b/backend/migrations/20260316000001_token_hash_pk_swap.up.sql @@ -0,0 +1,22 @@ +-- Step 2: Swap PK and update trigger. +-- All operations here are instant metadata changes (no data/index rebuild). +-- The ACCESS EXCLUSIVE lock is held for only milliseconds. + +-- Swap primary key: drop old, promote existing unique index (instant) +ALTER TABLE token DROP CONSTRAINT token_pkey; +ALTER TABLE token ADD CONSTRAINT token_pkey PRIMARY KEY USING INDEX token_hash_unique; + +-- Make old token column nullable (no longer written for new tokens) +ALTER TABLE token ALTER COLUMN token DROP NOT NULL; + +-- Update the cache invalidation trigger to send prefix instead of plaintext +CREATE OR REPLACE FUNCTION notify_token_invalidation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.label = 'session' AND OLD.email IS NOT NULL THEN + INSERT INTO notify_event (channel, payload) + VALUES ('notify_token_invalidation', OLD.token_prefix); + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/backend/migrations/20260316000002_native_trigger_token_hash.down.sql b/backend/migrations/20260316000002_native_trigger_token_hash.down.sql new file mode 100644 index 0000000000..c45ef7fd01 --- /dev/null +++ b/backend/migrations/20260316000002_native_trigger_token_hash.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE native_trigger ADD COLUMN webhook_token_prefix VARCHAR(10) NOT NULL DEFAULT ''; + +-- Backfill prefix from the token table +UPDATE native_trigger nt +SET webhook_token_prefix = t.token_prefix +FROM token t +WHERE t.token_hash = nt.webhook_token_hash; + +ALTER TABLE native_trigger DROP COLUMN IF EXISTS webhook_token_hash; diff --git a/backend/migrations/20260316000002_native_trigger_token_hash.up.sql b/backend/migrations/20260316000002_native_trigger_token_hash.up.sql new file mode 100644 index 0000000000..fd643ebaa1 --- /dev/null +++ b/backend/migrations/20260316000002_native_trigger_token_hash.up.sql @@ -0,0 +1,22 @@ +-- Add webhook_token_hash to native_trigger for safe token lookups/deletes, +-- and drop webhook_token_prefix which is no longer needed. + +ALTER TABLE native_trigger ADD COLUMN webhook_token_hash VARCHAR(64); + +-- Backfill from the token table +UPDATE native_trigger nt +SET webhook_token_hash = t.token_hash +FROM token t +WHERE t.token_prefix = nt.webhook_token_prefix; + +-- Mark orphaned triggers (whose tokens no longer exist) with an error +-- instead of deleting them, so they remain visible in the UI. +-- Use a placeholder hash (sha256 of empty string) that won't match any real token. +UPDATE native_trigger +SET webhook_token_hash = encode(sha256(''::bytea), 'hex'), + error = 'Webhook token not found during migration — re-create this trigger to fix' +WHERE webhook_token_hash IS NULL; + +ALTER TABLE native_trigger ALTER COLUMN webhook_token_hash SET NOT NULL; + +ALTER TABLE native_trigger DROP COLUMN webhook_token_prefix; diff --git a/backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql b/backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql new file mode 100644 index 0000000000..1b4895e88f --- /dev/null +++ b/backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql @@ -0,0 +1,5 @@ +-- Hashed values cannot be reversed; truncate to avoid silent join mismatches +-- with the old code that compared plaintext token_expiry_notification.token against token.token. +TRUNCATE token_expiry_notification; + +ALTER TABLE token_expiry_notification RENAME COLUMN token_hash TO token; diff --git a/backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql b/backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql new file mode 100644 index 0000000000..fd4186e725 --- /dev/null +++ b/backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql @@ -0,0 +1,5 @@ +-- Convert existing plaintext token values to SHA-256 hashes, then rename the column. +UPDATE token_expiry_notification +SET token = encode(sha256(token::bytea), 'hex'); + +ALTER TABLE token_expiry_notification RENAME COLUMN token TO token_hash; diff --git a/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql new file mode 100644 index 0000000000..9e3c94eea1 --- /dev/null +++ b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql @@ -0,0 +1,4 @@ +-- Hashing is irreversible so the values will be stale hashes, but the old +-- code's DELETE FROM token WHERE token = $1 is non-fatal — refresh tokens +-- keep working, only old access token cleanup silently fails. +ALTER TABLE mcp_oauth_refresh_token RENAME COLUMN access_token_hash TO access_token; diff --git a/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql new file mode 100644 index 0000000000..e845eb6527 --- /dev/null +++ b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql @@ -0,0 +1,6 @@ +-- Hash existing plaintext access_token values in mcp_oauth_refresh_token. +-- Only hash rows that are not already 64-char hex strings (safety guard for re-runs). +UPDATE mcp_oauth_refresh_token +SET access_token = encode(sha256(access_token::bytea), 'hex'); + +ALTER TABLE mcp_oauth_refresh_token RENAME COLUMN access_token TO access_token_hash; diff --git a/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql b/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql index 4b3df78534..30629d969d 100644 --- a/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql +++ b/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql @@ -803,7 +803,9 @@ ALTER TABLE public.script OWNER TO postgres; -- CREATE TABLE public.token ( - token character varying(50) NOT NULL, + token_hash character varying(64) NOT NULL, + token_prefix character varying(10) NOT NULL, + token character varying(50), label character varying(50), expiration timestamp with time zone, workspace_id character varying(50), @@ -1209,7 +1211,7 @@ ALTER TABLE ONLY public.script -- ALTER TABLE ONLY public.token - ADD CONSTRAINT token_pkey PRIMARY KEY (token); + ADD CONSTRAINT token_pkey PRIMARY KEY (token_hash); -- @@ -2534,7 +2536,7 @@ INSERT INTO public.usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO public.workspace_key(workspace_id, kind, key) VALUES ('test-workspace', 'cloud', 'test-key'); -insert INTO public.token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO public.token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( 'test-workspace', diff --git a/backend/src/main.rs b/backend/src/main.rs index aa9f16af89..24d9459937 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1686,7 +1686,7 @@ async fn process_notify_event( } "notify_token_invalidation" => { tracing::info!( - "Token invalidation detected for token: {}...", + "Token invalidation detected for prefix: {}...", payload.get(..8).unwrap_or(payload) ); windmill_api::auth::invalidate_token_from_cache(payload); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 803afabf0d..429577f3ef 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -945,7 +945,7 @@ pub async fn delete_expired_items(db: &DB) -> () { let expired_tokens_r = sqlx::query_as!( TokenRow, "DELETE FROM token WHERE expiration <= now() - RETURNING substring(token for 10) as token_prefix, label, email, workspace_id", + RETURNING token_prefix, label, email, workspace_id", ) .fetch_all(db) .await; @@ -1164,15 +1164,17 @@ pub async fn delete_expired_items(db: &DB) -> () { } pub async fn check_expiring_tokens(db: &DB) { - // Find tokens expiring within 7 days that still have a pending notification row + // Find tokens expiring within 7 days that still have a pending notification row. + // The notification table stores token_hash (not plaintext) so the join works + // even after the hash migration makes token.token nullable. let expiring_tokens_r = sqlx::query_as!( TokenRow, "DELETE FROM token_expiry_notification n USING token t - WHERE n.token = t.token + WHERE n.token_hash = t.token_hash AND n.expiration > now() AND n.expiration <= now() + interval '7 days' - RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id", + RETURNING t.token_prefix, t.label, t.email, t.workspace_id", ) .fetch_all(db) .await; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 2f86dcb322..5a272d5121 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -115,14 +115,14 @@ kafka_trigger: path(char), kafka_resource_path(char), topics(char), group_id(cha log_file: hostname(char), log_ts(ts), ok_lines(bigint), err_lines(bigint), mode(log_mode), worker_group(char), file_path(char), json_fmt(bool) magic_link: email(char), token(char), expiration(ts) mcp_oauth_client: mcp_server_url(text), client_id(text), client_secret(text), client_secret_expires_at(ts), token_endpoint(text), created_at(ts) -mcp_oauth_refresh_token: id(bigint), refresh_token(char), access_token(char), client_id(char), user_email(char), workspace_id(char), scopes(text[]), token_family(uuid), created_at(ts), expires_at(ts), used_at(ts), revoked(bool) +mcp_oauth_refresh_token: id(bigint), refresh_token(char), access_token_hash(char), client_id(char), user_email(char), workspace_id(char), scopes(text[]), token_family(uuid), created_at(ts), expires_at(ts), used_at(ts), revoked(bool) FK: (client_id) -> mcp_oauth_server_client(client_id) mcp_oauth_server_client: client_id(char), client_name(char), redirect_uris(text[]), created_at(ts) mcp_oauth_server_code: code(char), client_id(char), user_email(char), workspace_id(char), scopes(text[]), redirect_uri(text), code_challenge(char), code_challenge_method(char), created_at(ts), expires_at(ts) FK: (client_id) -> mcp_oauth_server_client(client_id) metrics: id(char), value(jsonb), created_at(ts) mqtt_trigger: mqtt_resource_path(char), subscribe_topics(jsonb[]), client_version(mqtt_client_version), v5_config(jsonb), v3_config(jsonb), client_id(char), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) -native_trigger: external_id(char), workspace_id(char), service_name(native_trigger_service), script_path(char), is_flow(bool), webhook_token_prefix(char), service_config(jsonb), error(text), created_at(ts), updated_at(ts) +native_trigger: external_id(char), workspace_id(char), service_name(native_trigger_service), script_path(char), is_flow(bool), webhook_token_hash(char), service_config(jsonb), error(text), created_at(ts), updated_at(ts) FK: (workspace_id) -> workspace(id) nats_trigger: path(char), nats_resource_path(char), subjects(char), stream_name(char), consumer_name(char), use_jetstream(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) FK: (workspace_id) -> workspace(id) @@ -151,9 +151,9 @@ script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), s skip_workspace_diff_tally: workspace_id(char), added_at(ts) sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) FK: (workspace_id) -> workspace(id) -token: token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid) +token: token_hash(char), token_prefix(char), token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid) FK: (workspace_id) -> workspace(id) -token_expiry_notification: token(char), expiration(ts) +token_expiry_notification: token_hash(char), expiration(ts) INDEX: idx_token_expiry_notification_expiration (expiration) tutorial_progress: email(char), progress(bit64), skipped_all(bool) unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts) diff --git a/backend/tests/fixtures/base.sql b/backend/tests/fixtures/base.sql index 7db9918fba..ce5df2640e 100644 --- a/backend/tests/fixtures/base.sql +++ b/backend/tests/fixtures/base.sql @@ -33,9 +33,11 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/tests/fixtures/end_user_email.sql b/backend/tests/fixtures/end_user_email.sql index 654ad93680..17d459f7de 100644 --- a/backend/tests/fixtures/end_user_email.sql +++ b/backend/tests/fixtures/end_user_email.sql @@ -24,15 +24,17 @@ VALUES ('other-ws@windmill.dev', 'hash', 'password', false, true, 'Other WS User INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('other-workspace', 'other-ws@windmill.dev', 'other-ws-user', true, 'Admin'); -INSERT INTO token(token, email, label, super_admin) -VALUES ('OTHER_WS_TOKEN', 'other-ws@windmill.dev', 'other ws token', false); +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('OTHER_WS_TOKEN'::bytea), 'hex'), 'OTHER_WS_T', 'OTHER_WS_TOKEN', 'other-ws@windmill.dev', 'other ws token', false); -- User not in any workspace INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) VALUES ('no-ws@windmill.dev', 'hash', 'password', false, true, 'No WS User'); -INSERT INTO token(token, email, label, super_admin) -VALUES ('NO_WS_TOKEN', 'no-ws@windmill.dev', 'no ws token', false); +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('NO_WS_TOKEN'::bytea), 'hex'), 'NO_WS_TOKE', 'NO_WS_TOKEN', 'no-ws@windmill.dev', 'no ws token', false); -- Script that returns WM_END_USER_EMAIL (public via extra_perms) INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, extra_perms) diff --git a/backend/tests/fixtures/permissions_test.sql b/backend/tests/fixtures/permissions_test.sql index a9006f5233..66f406c08c 100644 --- a/backend/tests/fixtures/permissions_test.sql +++ b/backend/tests/fixtures/permissions_test.sql @@ -39,13 +39,13 @@ ON CONFLICT (email) DO NOTHING; -- Tokens associated with emails (workspace-scoped) -- The auth system will look up the user by email in the usr table -- Note: tokens must be at least 10 characters (TOKEN_PREFIX_LEN) -INSERT INTO token (token, email, label, super_admin, owner, workspace_id) +INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, owner, workspace_id) VALUES - ('ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), - ('ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), - ('BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), - ('CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), - ('OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); + (encode(sha256('ADMIN_TOKEN_TEST'::bytea), 'hex'), 'ADMIN_TOKE', 'ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), + (encode(sha256('ALICE_TOKEN_TEST'::bytea), 'hex'), 'ALICE_TOKE', 'ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), + (encode(sha256('BOB_TOKEN_TEST12'::bytea), 'hex'), 'BOB_TOKEN_', 'BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), + (encode(sha256('CHARLIE_TOKEN_01'::bytea), 'hex'), 'CHARLIE_TO', 'CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), + (encode(sha256('OPERATOR_TOKEN_1'::bytea), 'hex'), 'OPERATOR_T', 'OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); -- ============================================ -- GROUPS diff --git a/backend/tests/fixtures/preserve_on_behalf_of.sql b/backend/tests/fixtures/preserve_on_behalf_of.sql index 514c554960..7467843e80 100644 --- a/backend/tests/fixtures/preserve_on_behalf_of.sql +++ b/backend/tests/fixtures/preserve_on_behalf_of.sql @@ -65,14 +65,20 @@ INSERT INTO usr_to_group(workspace_id, group_, usr) VALUES ('test-workspace', 'wm_deployers', 'deployer-user') ON CONFLICT DO NOTHING; --- Tokens for all users -INSERT INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true) +-- Tokens for all users (token_hash = sha256 hex, token_prefix = first 10 chars) +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true) ON CONFLICT DO NOTHING; -INSERT INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false) +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false) ON CONFLICT DO NOTHING; -INSERT INTO token(token, email, label, super_admin) VALUES ('DEPLOYER_TOKEN', 'deployer@windmill.dev', 'deployer token', false) +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('DEPLOYER_TOKEN'::bytea), 'hex'), 'DEPLOYER_T', 'DEPLOYER_TOKEN', 'deployer@windmill.dev', 'deployer token', false) ON CONFLICT DO NOTHING; -INSERT INTO token(token, email, label, super_admin) VALUES ('ORIGINAL_TOKEN', 'original@windmill.dev', 'original token', false) +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('ORIGINAL_TOKEN'::bytea), 'hex'), 'ORIGINAL_T', 'ORIGINAL_TOKEN', 'original@windmill.dev', 'original token', false) ON CONFLICT DO NOTHING; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index af212dc315..88c3b0cf0b 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -26,7 +26,9 @@ use tokio::sync::RwLock; use windmill_common::DB; use windmill_common::{ - auth::{get_folders_for_user, get_groups_for_user, JWTAuthClaims, TOKEN_PREFIX_LEN}, + auth::{ + get_folders_for_user, get_groups_for_user, hash_token, safe_token_prefix, JWTAuthClaims, + }, error::{Error, JsonResult}, jwt, users::{COOKIE_NAME, SUPERADMIN_SECRET_EMAIL}, @@ -42,13 +44,14 @@ lazy_static::lazy_static! { /// Get email from a valid token, with caching. /// Used for WM_END_USER_EMAIL when user is authenticated but not a workspace member. async fn get_email_from_token(db: &DB, token: &str) -> Option { - if let Some(cached) = TOKEN_EMAIL_CACHE.get(token) { + let t_hash = hash_token(token); + if let Some(cached) = TOKEN_EMAIL_CACHE.get(&t_hash) { return cached; } let email = sqlx::query_scalar!( - "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)", - token + "SELECT email FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + t_hash ) .fetch_optional(db) .await @@ -56,7 +59,7 @@ async fn get_email_from_token(db: &DB, token: &str) -> Option { .flatten() .flatten(); // email column is nullable, so we get Option> - TOKEN_EMAIL_CACHE.insert(token.to_string(), email.clone()); + TOKEN_EMAIL_CACHE.insert(t_hash, email.clone()); email } @@ -75,13 +78,15 @@ pub async fn get_end_user_email( } None } -// Global function to invalidate a specific token from cache -pub fn invalidate_token_from_cache(token: &str) { - // Remove all cache entries for this token (across all workspaces) - AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| cached_token != token); +// Global function to invalidate tokens from cache by prefix +pub fn invalidate_token_from_cache(token_prefix: &str) { + // Remove all cache entries whose raw token starts with this prefix (across all workspaces) + AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| { + !cached_token.starts_with(token_prefix) + }); tracing::info!( - "Invalidated token from auth cache: {}...", - &token[..token.len().min(8)] + "Invalidated token(s) from auth cache with prefix: {}...", + &token_prefix[..token_prefix.len().min(8)] ); } @@ -211,13 +216,14 @@ impl AuthCache { } } _ => { + let t_hash = hash_token(token); let user_o = sqlx::query!( "UPDATE token SET last_used_at = now() WHERE - token = $1 + token_hash = $1 AND (expiration > NOW() OR expiration IS NULL) AND (workspace_id IS NULL OR workspace_id = $2) RETURNING owner, email, super_admin, scopes, label", - token, + t_hash, w_id.as_ref(), ) .map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label)) @@ -275,7 +281,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }) } else { @@ -299,7 +305,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }) } @@ -315,7 +321,7 @@ impl AuthCache { folders, scopes: None, username_override, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(token)), }) } } @@ -364,7 +370,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }) } @@ -378,7 +384,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }), None => None, @@ -393,7 +399,7 @@ impl AuthCache { folders: Vec::new(), scopes, username_override, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(token)), }) } } @@ -427,7 +433,7 @@ impl AuthCache { folders: Vec::new(), scopes: None, username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(token)), }; Some(OptJobAuthed { authed, job_id: None }) } else { @@ -717,7 +723,7 @@ fn username_override_from_label(label: Option) -> Option { #[derive(FromRow, Serialize)] pub struct TruncatedTokenWithEmail { pub label: Option, - pub token_prefix: Option, + pub token_prefix: String, pub expiration: Option>, pub created_at: chrono::DateTime, pub last_used_at: chrono::DateTime, @@ -736,7 +742,7 @@ pub async fn list_tokens_internal( TruncatedTokenWithEmail, r#" SELECT label, - concat(substring(token for 10)) AS token_prefix, + token_prefix, expiration, created_at, last_used_at, @@ -759,7 +765,7 @@ pub async fn list_tokens_internal( TruncatedTokenWithEmail, r#" SELECT label, - concat(substring(token for 10)) AS token_prefix, + token_prefix, expiration, created_at, last_used_at, diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 557ce7f706..aceef77e01 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -18,7 +18,10 @@ use http::request::Parts; use windmill_audit::audit_oss::AuditAuthorable; use windmill_common::{ - auth::{fetch_authed_from_permissioned_as, is_devops_email, is_super_admin_email}, + auth::{ + fetch_authed_from_permissioned_as, hash_token, is_devops_email, is_super_admin_email, + TOKEN_PREFIX_LEN, + }, db::{Authable, Authed, AuthedRef}, error::{self, Error, Result}, users::username_to_permissioned_as, @@ -511,9 +514,20 @@ pub async fn create_token_internal( ) -> Result { use tracing::Instrument; use windmill_audit::{audit_oss::audit_log, ActionKind}; - use windmill_common::{utils::rd_string, worker::CLOUD_HOSTED}; + use windmill_common::{ + min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH, utils::rd_string, worker::CLOUD_HOSTED, + }; let token = rd_string(32); + let t_hash = hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); + + // Write plaintext token column until all workers support hash-based lookup + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&token) + }; let is_super_admin = sqlx::query_scalar!( "SELECT super_admin FROM password WHERE email = $1", @@ -536,12 +550,14 @@ pub async fn create_token_internal( } let rows = sqlx::query!( "INSERT INTO token - (token, email, label, expiration, super_admin, scopes, workspace_id) - SELECT $1, $2, $3, $4, $5, $6, $7 - WHERE $7::varchar IS NULL OR NOT EXISTS( - SELECT 1 FROM workspace WHERE id = $7 AND deleted = true + (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9 + WHERE $9::varchar IS NULL OR NOT EXISTS( + SELECT 1 FROM workspace WHERE id = $9 AND deleted = true )", - token, + t_hash, + t_prefix, + plaintext as Option<&str>, authed.email, token_config.label, token_config.expiration, @@ -559,7 +575,7 @@ pub async fn create_token_internal( register_token_expiry_notification( &mut *tx, - &token, + &t_hash, token_config.label.as_deref(), token_config.expiration, ) @@ -571,7 +587,7 @@ pub async fn create_token_internal( "users.token.create", ActionKind::Create, &"global", - Some(&token[0..10]), + Some(t_prefix), None, ) .instrument(tracing::info_span!("token", email = &authed.email)) @@ -581,12 +597,14 @@ pub async fn create_token_internal( } /// Insert a pending expiry notification row for user tokens that have an expiration. +/// Stores the token_hash so the join in check_expiring_tokens works even when +/// the plaintext token column is NULL (after hash migration). /// When updating this filter, also update: /// - `is_user_token` in src/monitor.rs /// - `isUserToken` in frontend/src/lib/components/settings/TokensTable.svelte pub async fn register_token_expiry_notification( tx: &mut sqlx::PgConnection, - token: &str, + token_hash: &str, label: Option<&str>, expiration: Option>, ) { @@ -602,8 +620,8 @@ pub async fn register_token_expiry_notification( return; } if let Err(e) = sqlx::query!( - "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", - token, + "INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", + token_hash, expiration, ) .execute(&mut *tx) diff --git a/backend/windmill-api-integration-tests/tests/fixtures/base.sql b/backend/windmill-api-integration-tests/tests/fixtures/base.sql index 7db9918fba..ce5df2640e 100644 --- a/backend/windmill-api-integration-tests/tests/fixtures/base.sql +++ b/backend/windmill-api-integration-tests/tests/fixtures/base.sql @@ -33,9 +33,11 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql b/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql index a9006f5233..66f406c08c 100644 --- a/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql +++ b/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql @@ -39,13 +39,13 @@ ON CONFLICT (email) DO NOTHING; -- Tokens associated with emails (workspace-scoped) -- The auth system will look up the user by email in the usr table -- Note: tokens must be at least 10 characters (TOKEN_PREFIX_LEN) -INSERT INTO token (token, email, label, super_admin, owner, workspace_id) +INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, owner, workspace_id) VALUES - ('ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), - ('ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), - ('BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), - ('CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), - ('OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); + (encode(sha256('ADMIN_TOKEN_TEST'::bytea), 'hex'), 'ADMIN_TOKE', 'ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), + (encode(sha256('ALICE_TOKEN_TEST'::bytea), 'hex'), 'ALICE_TOKE', 'ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), + (encode(sha256('BOB_TOKEN_TEST12'::bytea), 'hex'), 'BOB_TOKEN_', 'BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), + (encode(sha256('CHARLIE_TOKEN_01'::bytea), 'hex'), 'CHARLIE_TO', 'CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), + (encode(sha256('OPERATOR_TOKEN_1'::bytea), 'hex'), 'OPERATOR_T', 'OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); -- ============================================ -- GROUPS diff --git a/backend/windmill-api-integration-tests/tests/permissions.rs b/backend/windmill-api-integration-tests/tests/permissions.rs index 4342c61235..c333cf924b 100644 --- a/backend/windmill-api-integration-tests/tests/permissions.rs +++ b/backend/windmill-api-integration-tests/tests/permissions.rs @@ -517,9 +517,14 @@ async fn test_group_permission_inheritance(db: Pool) -> anyhow::Result // for (workspace_id, token) tuples. Since we can't easily clear it from tests, // we use a different token or wait for cache expiry. For this test, we create // a new token for Charlie. + let charlie_token = "CHARLIE_TOKEN_NEW"; + let charlie_token_hash = windmill_common::utils::calculate_hash(charlie_token); + let charlie_token_prefix = &charlie_token[..10.min(charlie_token.len())]; sqlx::query!( - "INSERT INTO token (token, email, label, super_admin, owner, workspace_id) - VALUES ('CHARLIE_TOKEN_NEW', 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')" + "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id) + VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", + charlie_token_hash, + charlie_token_prefix, ) .execute(&db) .await?; @@ -563,28 +568,100 @@ async fn test_all_item_types_permissions(db: Pool) -> anyhow::Result<( let bob_client = create_client_for_user(port, "BOB_TOKEN_TEST12").await; // Test Scripts - uses /scripts/get/p/{path} - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script") + ) + .await + ); // Test Flows - uses /flows/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow") + ) + .await + ); // Test Resources - uses /resources/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource") + ) + .await + ); // Test Variables - uses /variables/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable") + ) + .await + ); // Test Schedules - uses /schedules/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule") + ) + .await + ); // Test Apps - uses /apps/get/p/{path} - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app") + ) + .await + ); Ok(()) } @@ -747,11 +824,9 @@ async fn test_operator_cannot_create_update(db: Pool) -> anyhow::Resul .await?; // Update app versions - sqlx::query!( - "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001" - ) - .execute(&db) - .await?; + sqlx::query!("UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001") + .execute(&db) + .await?; let update_app = json!({ "path": "u/operator/existing_app", diff --git a/backend/windmill-api-integration-tests/tests/token_hash.rs b/backend/windmill-api-integration-tests/tests/token_hash.rs new file mode 100644 index 0000000000..aa7dd22286 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/token_hash.rs @@ -0,0 +1,399 @@ +//! Tests for the token hash migration. +//! +//! Verifies that: +//! - Rust hash_token() matches PostgreSQL's encode(sha256(...),'hex') +//! - Newly created tokens can authenticate immediately +//! - Token list/delete-by-prefix works with the new token_prefix column +//! - Logout invalidates tokens via hash-based deletion +//! - Backward compat: plaintext column is populated when old workers exist +//! - rotate_webhook_token produces valid tokens and defers old token deletion + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::auth::{hash_token, TOKEN_PREFIX_LEN}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn authed_with(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +/// Test 1: Verify that Rust's hash_token() produces the same hash as PostgreSQL's +/// encode(sha256(token::bytea), 'hex'). This is the foundational invariant. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_hash_consistency(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + // Compute hash in Rust + let rust_hash = hash_token("SECRET_TOKEN"); + + // Compute hash in PostgreSQL + let pg_hash: String = + sqlx::query_scalar!("SELECT encode(sha256('SECRET_TOKEN'::bytea), 'hex') AS hash") + .fetch_one(&db) + .await? + .unwrap(); + + assert_eq!( + rust_hash, pg_hash, + "Rust hash_token() must match PostgreSQL sha256()" + ); + + // Also verify it matches what's stored in the fixture + let stored_hash: String = sqlx::query_scalar!( + "SELECT token_hash FROM token WHERE email = 'test@windmill.dev' AND label = 'test token'" + ) + .fetch_one(&db) + .await?; + + assert_eq!( + rust_hash, stored_hash, + "hash_token() must match the fixture's pre-computed hash" + ); + + Ok(()) +} + +/// Test 2: Create a token via API, then immediately use it to authenticate. +/// Verifies create_token_internal stores the hash correctly and auth lookups work. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_create_token_and_auth(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + + // Create a new token + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "test-hash-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let new_token = resp.text().await?; + assert!(!new_token.is_empty()); + + // Use the new token to call whoami + let resp = authed_with(client().get(format!("{base}/whoami")), &new_token) + .send() + .await?; + assert_eq!(resp.status(), 200, "newly created token must authenticate"); + let body = resp.json::().await?; + assert_eq!(body["email"], "test@windmill.dev"); + + // Verify the hash is stored correctly in DB + let expected_hash = hash_token(&new_token); + let db_hash: Option = sqlx::query_scalar!( + "SELECT token_hash FROM token WHERE token_hash = $1", + expected_hash + ) + .fetch_optional(&db) + .await?; + assert!(db_hash.is_some(), "token_hash must be stored in DB"); + + Ok(()) +} + +/// Test 3: Create a token, list tokens (verify prefix), delete by prefix, confirm invalid. +/// Covers the change from WHERE token LIKE to WHERE token_prefix = $1. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_token_list_and_delete_by_prefix(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + + // Create a token + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "prefix-test-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let new_token = resp.text().await?; + let prefix = &new_token[..TOKEN_PREFIX_LEN]; + + // List tokens and find our token by prefix + let resp = authed(client().get(format!("{base}/tokens/list"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + let tokens = resp.json::>().await?; + let found = tokens + .iter() + .any(|t| t["token_prefix"].as_str() == Some(prefix)); + assert!(found, "token with prefix {prefix} must appear in list"); + + // Verify the new token works + let resp = authed_with(client().get(format!("{base}/whoami")), &new_token) + .send() + .await?; + assert_eq!(resp.status(), 200); + + // Delete by prefix + let resp = authed(client().delete(format!("{base}/tokens/delete/{prefix}"))) + .send() + .await?; + assert_eq!(resp.status(), 200, "delete token: {}", resp.text().await?); + + // Confirm the token is gone from the DB (auth cache may still serve 200 briefly) + let token_hash = hash_token(&new_token); + let deleted: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + token_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!( + !deleted, + "token must be deleted from DB after delete-by-prefix" + ); + + Ok(()) +} + +/// Test 4: Logout invalidates a token via hash-based deletion. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_logout_invalidates_token(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + let auth_base = format!("http://localhost:{port}/api/auth"); + + // Create a fresh token (don't burn the fixture token) + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "logout-test-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let token = resp.text().await?; + + // Verify it works + let resp = authed_with(client().get(format!("{base}/whoami")), &token) + .send() + .await?; + assert_eq!(resp.status(), 200); + + // Logout with the token + let resp = authed_with(client().post(format!("{auth_base}/logout")), &token) + .send() + .await?; + assert!( + resp.status() == 200 || resp.status() == 303, + "logout: unexpected status {}", + resp.status() + ); + + // Confirm the token is gone from the DB (auth cache may still serve 200 briefly) + let token_hash = hash_token(&token); + let exists: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + token_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!(!exists, "token must be deleted from DB after logout"); + + Ok(()) +} + +/// Test 5: Backward compatibility — plaintext column behavior based on MIN_VERSION. +/// When old workers exist (MIN_VERSION < 1.650.0), plaintext must be written so +/// old workers running WHERE token = $1 can still authenticate. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_plaintext_backward_compat(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + use windmill_common::min_version::{MIN_VERSION, MIN_VERSION_SUPPORTS_TOKEN_HASH}; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + + // --- Phase 1: Simulate old workers present (version < 1.650.0) --- + // Set MIN_VERSION to one minor below the token hash feature version + let mut old_version = MIN_VERSION_SUPPORTS_TOKEN_HASH.version().clone(); + old_version.minor -= 1; + *MIN_VERSION.write().await = old_version; + + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "old-worker-compat-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let old_compat_token = resp.text().await?; + let old_compat_hash = hash_token(&old_compat_token); + + // Plaintext should be stored (for old workers) + let plaintext: Option = sqlx::query_scalar!( + "SELECT token FROM token WHERE token_hash = $1", + old_compat_hash + ) + .fetch_one(&db) + .await?; + assert!( + plaintext.is_some(), + "plaintext must be stored when old workers exist" + ); + assert_eq!(plaintext.unwrap(), old_compat_token); + + // Old-style query (what old workers run) must find the token + let old_style_email: Option = sqlx::query_scalar!( + "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)", + &old_compat_token + ) + .fetch_optional(&db) + .await? + .flatten(); + assert_eq!( + old_style_email.as_deref(), + Some("test@windmill.dev"), + "old-style WHERE token = $1 must find the token" + ); + + // New-style query must also work + let new_style_email: Option = sqlx::query_scalar!( + "SELECT email FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + old_compat_hash + ) + .fetch_optional(&db) + .await? + .flatten(); + assert_eq!( + new_style_email.as_deref(), + Some("test@windmill.dev"), + "new-style WHERE token_hash = $1 must also work" + ); + + // --- Phase 2: All workers upgraded (version >= 1.650.0) --- + *MIN_VERSION.write().await = MIN_VERSION_SUPPORTS_TOKEN_HASH.version().clone(); + + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "new-worker-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let new_token = resp.text().await?; + let new_hash = hash_token(&new_token); + + // Plaintext should NOT be stored + let plaintext: Option = + sqlx::query_scalar!("SELECT token FROM token WHERE token_hash = $1", new_hash) + .fetch_one(&db) + .await?; + assert!( + plaintext.is_none(), + "plaintext must be NULL when all workers support hash" + ); + + // Old-style query should NOT find this token + let old_style_result: Option = + sqlx::query_scalar!("SELECT email FROM token WHERE token = $1", &new_token) + .fetch_optional(&db) + .await? + .flatten(); + assert!( + old_style_result.is_none(), + "old-style query must not find token when plaintext is NULL" + ); + + // New-style query must still work + let resp = authed_with(client().get(format!("{base}/whoami")), &new_token) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "new token must authenticate via hash lookup" + ); + + Ok(()) +} + +/// Test 6: rotate_webhook_token creates a new token and keeps the old one alive. +/// Callers delete the old token after successfully updating the trigger. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + use windmill_native_triggers::{delete_token_by_hash, rotate_webhook_token}; + + // Insert a token directly with known values + let original_token = "test-webhook-token-original-1234"; + let original_hash = hash_token(original_token); + let original_prefix = &original_token[..TOKEN_PREFIX_LEN]; + + sqlx::query!( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin) + VALUES ($1, $2, $3, 'test@windmill.dev', 'webhook-test', false)", + original_hash, + original_prefix, + original_token, + ) + .execute(&db) + .await?; + + // Rotate the token + let rotated = rotate_webhook_token(&db, &original_hash) + .await? + .expect("rotate must return Some for existing token"); + + // New token should be different + assert_ne!(rotated.new_token, original_token); + assert_eq!(rotated.old_token_hash, original_hash); + + // New token's hash should exist in DB + let new_hash = hash_token(&rotated.new_token); + let exists: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + new_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + assert!(exists, "new token hash must exist in DB after rotation"); + + // Old token should still exist (deletion deferred to caller) + let old_exists: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + original_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + assert!( + old_exists, + "old token must still exist until caller deletes it" + ); + + // Caller deletes old token after successful trigger update + let deleted = delete_token_by_hash(&db, &rotated.old_token_hash).await?; + assert!(deleted, "old token must be deletable"); + + // Old token should now be gone + let old_gone: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + original_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!(!old_gone, "old token must be gone after explicit deletion"); + + // Rotating a non-existent hash should return None + let result = rotate_webhook_token(&db, "nonexistent_hash").await?; + assert!( + result.is_none(), + "rotating a non-existent token must return None" + ); + + Ok(()) +} diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index f7409169a9..b911739eaa 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -42,7 +42,7 @@ use tracing::Instrument; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::audit::AuditAuthor; -use windmill_common::auth::TOKEN_PREFIX_LEN; +use windmill_common::auth::{safe_token_prefix, TOKEN_PREFIX_LEN}; use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING; use windmill_common::oauth2::InstanceEvent; use windmill_common::users::truncate_token; @@ -235,7 +235,7 @@ pub struct EditLoginType { #[derive(FromRow, Serialize)] pub struct TruncatedToken { pub label: Option, - pub token_prefix: Option, + pub token_prefix: String, pub expiration: Option>, pub created_at: chrono::DateTime, pub last_used_at: chrono::DateTime, @@ -527,23 +527,28 @@ async fn logout( } cookies.remove(cookie); let mut tx = db.begin().await?; + let t_hash = windmill_common::auth::hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); let email = if *INVALIDATE_ALL_SESSIONS_ON_LOGOUT { sqlx::query_scalar!( "WITH email_lookup AS ( - SELECT email FROM token WHERE token = $1 + SELECT email FROM token WHERE token_hash = $1 ) DELETE FROM token WHERE email = (SELECT email FROM email_lookup) AND label = 'session' RETURNING email", - token + t_hash ) .fetch_optional(&mut *tx) .await? } else { - sqlx::query_scalar!("DELETE FROM token WHERE token = $1 RETURNING email", token) - .fetch_optional(&mut *tx) - .await? + sqlx::query_scalar!( + "DELETE FROM token WHERE token_hash = $1 RETURNING email", + t_hash + ) + .fetch_optional(&mut *tx) + .await? }; if let Some(email) = email { @@ -559,7 +564,7 @@ async fn logout( email: email.clone(), username: email, username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(t_prefix.to_string()), }, audit_message, ActionKind::Delete, @@ -1643,7 +1648,7 @@ async fn login( email: email.clone(), username: email.clone(), username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(&token)), }; audit_log( @@ -1689,7 +1694,8 @@ async fn refresh_token( let mut tx = db.begin().await?; if let Some(thresh_s) = query.if_expiring_in_less_than_s { - let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &token, thresh_s) + let t_hash = windmill_common::auth::hash_token(&token); + let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &t_hash, thresh_s) .fetch_optional(&db) .await? .flatten() @@ -1740,7 +1746,16 @@ pub async fn create_session_token<'c>( tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, cookies: Cookies, ) -> Result { + use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; + let token = rd_string(32); + let t_hash = windmill_common::auth::hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&token) + }; if *INVALIDATE_OLD_SESSIONS { sqlx::query!( @@ -1756,7 +1771,7 @@ pub async fn create_session_token<'c>( email: email.to_string(), username: email.to_string(), username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(t_prefix.to_string()), }, "users.token.invalidate_old_sessions", ActionKind::Delete, @@ -1770,9 +1785,11 @@ pub async fn create_session_token<'c>( sqlx::query!( "INSERT INTO token - (token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5)", - token, + (token_hash, token_prefix, token, email, label, expiration, super_admin) + VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", + t_hash, + t_prefix, + plaintext as Option<&str>, email, "session", &MAX_SESSION_VALIDITY_SECONDS.to_string(), @@ -1817,7 +1834,16 @@ async fn impersonate( authed: ApiAuthed, Json(new_token): Json, ) -> Result<(StatusCode, String)> { + use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; + let token = rd_string(32); + let t_hash = windmill_common::auth::hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&token) + }; require_super_admin(&db, &authed.email).await?; if new_token.impersonate_email.is_none() { @@ -1839,9 +1865,11 @@ async fn impersonate( sqlx::query!( "INSERT INTO token - (token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, $4, $5)", - token, + (token_hash, token_prefix, token, email, label, expiration, super_admin) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + t_hash, + t_prefix, + plaintext as Option<&str>, impersonated, new_token.label, new_token.expiration, @@ -1852,7 +1880,7 @@ async fn impersonate( windmill_api_auth::register_token_expiry_notification( &mut *tx, - &token, + &t_hash, new_token.label.as_deref(), new_token.expiration, ) @@ -1864,7 +1892,7 @@ async fn impersonate( "users.impersonate", ActionKind::Delete, &"global", - Some(&token[0..10]), + Some(t_prefix), Some([("impersonated", &format!("{impersonated}")[..])].into()), ) .instrument(tracing::info_span!("token", email = &impersonated)) @@ -1888,7 +1916,7 @@ async fn list_tokens( let rows = if query.exclude_ephemeral.unwrap_or(false) { sqlx::query_as!( TruncatedToken, - "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \ + "SELECT label, token_prefix, expiration, created_at, \ last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, @@ -1900,7 +1928,7 @@ async fn list_tokens( } else { sqlx::query_as!( TruncatedToken, - "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \ + "SELECT label, token_prefix, expiration, created_at, \ last_used_at, scopes FROM token WHERE email = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, @@ -1923,8 +1951,8 @@ async fn delete_token( let tokens_deleted: Vec = sqlx::query_scalar( "DELETE FROM token WHERE email = $1 - AND token LIKE concat($2::text, '%') - RETURNING concat(substring(token for 10), '*****')", + AND token_prefix = $2 + RETURNING concat(token_prefix, '*****')", ) .bind(&authed.email) .bind(&token_prefix) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index a85383e496..2b3146a6fb 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3594,7 +3594,7 @@ pub(crate) async fn archive_workspace_impl( // Delete non-session tokens scoped to this workspace let deleted_tokens = sqlx::query_scalar!( - "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token", + "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token_prefix", w_id ) .fetch_all(&mut *tx) diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs index 9a81e2efd8..ecfada2fcb 100644 --- a/backend/windmill-api/src/mcp/oauth_server.rs +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::FromRow; use windmill_common::{ + auth::{hash_token, TOKEN_PREFIX_LEN}, error::{Error, Result}, + min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH, utils::rd_string, BASE_URL, DB, }; @@ -209,7 +211,7 @@ struct AuthorizationCode { struct RefreshTokenRow { id: i64, refresh_token: String, - access_token: String, + access_token_hash: String, client_id: String, user_email: String, workspace_id: String, @@ -383,16 +385,25 @@ async fn handle_authorization_code_grant( } let access_token = rd_string(32); + let access_token_hash = hash_token(&access_token); + let access_token_prefix = access_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&access_token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&access_token) + }; let refresh_token = rd_string(32); let token_family = sqlx::types::Uuid::new_v4(); let scopes = auth_code.scopes; // Create access token (rejects archived workspaces inline) let rows = sqlx::query!( - "INSERT INTO token (token, email, label, expiration, scopes, workspace_id) - SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar - WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", - access_token, + "INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id) + SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar + WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)", + access_token_hash, + access_token_prefix, + plaintext as Option<&str>, auth_code.user_email, format!("mcp-oauth-{}", auth_code.client_id), MCP_OAUTH_TOKEN_EXPIRATION_SECS.to_string(), @@ -411,13 +422,13 @@ async fn handle_authorization_code_grant( )); } - // Create refresh token + // Create refresh token — store the hash of the access token so we can delete it later let refresh_token_result = sqlx::query!( "INSERT INTO mcp_oauth_refresh_token - (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at) + (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", refresh_token, - access_token, + access_token_hash, auth_code.client_id, auth_code.user_email, auth_code.workspace_id, @@ -469,7 +480,7 @@ async fn handle_refresh_token_grant( AND used_at IS NULL AND NOT revoked AND expires_at > now() - RETURNING id, refresh_token, access_token, client_id, user_email, workspace_id, + RETURNING id, refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, created_at, expires_at, used_at, revoked", refresh_token_value, req.client_id @@ -504,10 +515,13 @@ async fn handle_refresh_token_grant( } }; - // Delete old access token - if let Err(e) = sqlx::query!("DELETE FROM token WHERE token = $1", token_row.access_token) - .execute(db) - .await + // Delete old access token using the stored hash + if let Err(e) = sqlx::query!( + "DELETE FROM token WHERE token_hash = $1", + token_row.access_token_hash + ) + .execute(db) + .await { tracing::error!("Failed to delete old access token: {}", e); // Non-fatal, continue with token creation @@ -515,15 +529,24 @@ async fn handle_refresh_token_grant( // Generate new tokens let new_access_token = rd_string(32); + let new_access_token_hash = hash_token(&new_access_token); + let new_access_token_prefix = new_access_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&new_access_token); + let new_plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&new_access_token) + }; let new_refresh_token = rd_string(32); let scopes = token_row.scopes; // Create new access token (rejects archived workspaces inline) let rows = sqlx::query!( - "INSERT INTO token (token, email, label, expiration, scopes, workspace_id) - SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar - WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", - new_access_token, + "INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id) + SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar + WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)", + new_access_token_hash, + new_access_token_prefix, + new_plaintext as Option<&str>, token_row.user_email, format!("mcp-oauth-{}", token_row.client_id), MCP_OAUTH_TOKEN_EXPIRATION_SECS.to_string(), @@ -542,13 +565,13 @@ async fn handle_refresh_token_grant( )); } - // Create new refresh token (same token family for tracking) + // Create new refresh token (same token family for tracking) — store hash of access token if let Err(e) = sqlx::query!( "INSERT INTO mcp_oauth_refresh_token - (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at) + (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", new_refresh_token, - new_access_token, + new_access_token_hash, token_row.client_id, token_row.user_email, token_row.workspace_id, diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 70e676b5b4..df1c83d6d5 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -834,7 +834,7 @@ pub(crate) async fn tarball_workspace( let trigger_str = &to_string_without_metadata( &trigger, false, - Some(vec!["webhook_token_prefix"]), + Some(vec!["webhook_token_hash"]), ) .unwrap(); archive diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 7024029c52..23604b6e3a 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -18,6 +18,12 @@ use crate::{ DB, }; +/// Hash a raw token using SHA-256 (hex-encoded, 64 chars). +/// Used to store and look up tokens without keeping plaintext in the DB. +pub fn hash_token(token: &str) -> String { + crate::utils::calculate_hash(token) +} + #[derive(Debug)] pub struct IdToken { token: String, @@ -26,6 +32,15 @@ pub struct IdToken { pub const TOKEN_PREFIX_LEN: usize = 10; +/// Safely extract the token prefix (first TOKEN_PREFIX_LEN chars). +/// Returns the full token if it's shorter than TOKEN_PREFIX_LEN, preventing panics. +pub fn safe_token_prefix(token: &str) -> String { + token + .get(..TOKEN_PREFIX_LEN) + .unwrap_or(token) + .to_string() +} + lazy_static::lazy_static! { // Cache for script hash permissions - (ApiAuthed hash, script_hash) -> permission result pub static ref HASH_PERMS_CACHE: PermsCache = PermsCache::new(); diff --git a/backend/windmill-common/src/min_version.rs b/backend/windmill-common/src/min_version.rs index fd1769dd55..11abecff9f 100644 --- a/backend/windmill-common/src/min_version.rs +++ b/backend/windmill-common/src/min_version.rs @@ -5,6 +5,7 @@ use tokio::sync::RwLock; // ============ Feature Definitions ============ +pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage"); pub const MIN_VERSION_SUPPORTS_SYNC_JOBS_DEBOUNCING: VC = vc(1, 602, 0, "Sync jobs debouncing"); pub const MIN_VERSION_SUPPORTS_DEBOUNCING_V2: VC = vc(1, 597, 0, "Debouncing V2"); pub const MIN_VERSION_IS_AT_LEAST_1_595: VC = vc(1, 595, 0, "Flow status separate table"); diff --git a/backend/windmill-common/tests/fixtures/base.sql b/backend/windmill-common/tests/fixtures/base.sql index 7db9918fba..412fa1029f 100644 --- a/backend/windmill-common/tests/fixtures/base.sql +++ b/backend/windmill-common/tests/fixtures/base.sql @@ -33,9 +33,9 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/windmill-common/tests/notify_events.rs b/backend/windmill-common/tests/notify_events.rs index 36302b8e66..b9f639caea 100644 --- a/backend/windmill-common/tests/notify_events.rs +++ b/backend/windmill-common/tests/notify_events.rs @@ -40,13 +40,20 @@ async fn count_events_for_channel(db: &Pool, channel: &str) -> i64 { #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_get_latest_event_id_returns_valid_id(db: Pool) { // Get current latest id - let latest_id = get_latest_event_id(&db).await.expect("Should get latest event id"); + let latest_id = get_latest_event_id(&db) + .await + .expect("Should get latest event id"); assert!(latest_id >= 0, "Latest id should be non-negative"); // Insert a new event and verify latest_id increases let new_id = insert_test_event(&db, "test_latest_id", "payload").await; - let new_latest_id = get_latest_event_id(&db).await.expect("Should get latest event id"); - assert!(new_latest_id >= new_id, "Latest id should be >= new event id"); + let new_latest_id = get_latest_event_id(&db) + .await + .expect("Should get latest event id"); + assert!( + new_latest_id >= new_id, + "Latest id should be >= new event id" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -55,7 +62,9 @@ async fn test_get_latest_event_id_with_events(db: Pool) { let _id2 = insert_test_event(&db, "test_channel_2", "payload2").await; let id3 = insert_test_event(&db, "test_channel_3", "payload3").await; - let latest_id = get_latest_event_id(&db).await.expect("Should get latest event id"); + let latest_id = get_latest_event_id(&db) + .await + .expect("Should get latest event id"); assert!(latest_id >= id3, "Latest id should be >= last inserted id"); } @@ -65,8 +74,13 @@ async fn test_poll_notify_events_no_new_events(db: Pool) { let latest_id = get_latest_event_id(&db).await.unwrap(); // Poll from the latest id - should return empty since no new events - let events = poll_notify_events(&db, latest_id).await.expect("Should poll events"); - assert!(events.is_empty(), "Should return empty vec when polling from latest id"); + let events = poll_notify_events(&db, latest_id) + .await + .expect("Should poll events"); + assert!( + events.is_empty(), + "Should return empty vec when polling from latest id" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -76,7 +90,9 @@ async fn test_poll_notify_events_returns_new_events(db: Pool) { let _id1 = insert_test_event(&db, "test_poll_channel", "payload1").await; let _id2 = insert_test_event(&db, "test_poll_channel", "payload2").await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); assert!(events.len() >= 2, "Should return at least 2 new events"); // Verify the events we inserted are present @@ -87,7 +103,10 @@ async fn test_poll_notify_events_returns_new_events(db: Pool) { assert_eq!(our_events.len(), 2, "Should have exactly our 2 test events"); // Verify ordering (ascending by id) - assert!(our_events[0].id < our_events[1].id, "Events should be ordered by id ascending"); + assert!( + our_events[0].id < our_events[1].id, + "Events should be ordered by id ascending" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -97,14 +116,19 @@ async fn test_poll_notify_events_respects_last_event_id(db: Pool) { let _id3 = insert_test_event(&db, "test_respect_id", "payload3").await; // Poll from id1 should only return id2 and id3 - let events = poll_notify_events(&db, id1).await.expect("Should poll events"); + let events = poll_notify_events(&db, id1) + .await + .expect("Should poll events"); let our_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_respect_id") .collect(); assert_eq!(our_events.len(), 2, "Should only return events after id1"); - assert!(our_events.iter().all(|e| e.id > id1), "All events should have id > id1"); + assert!( + our_events.iter().all(|e| e.id > id1), + "All events should have id > id1" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -124,21 +148,24 @@ async fn test_cleanup_old_events(db: Pool) { .expect("Failed to insert old event"); // Insert a recent event - sqlx::query( - "INSERT INTO notify_event (channel, payload) VALUES ($1, $2)", - ) - .bind(&recent_channel) - .bind("recent_payload") - .execute(&db) - .await - .expect("Failed to insert recent event"); + sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)") + .bind(&recent_channel) + .bind("recent_payload") + .execute(&db) + .await + .expect("Failed to insert recent event"); // Count before cleanup let old_count_before = count_events_for_channel(&db, &old_channel).await; - assert_eq!(old_count_before, 1, "Should have 1 old event before cleanup"); + assert_eq!( + old_count_before, 1, + "Should have 1 old event before cleanup" + ); // Cleanup events older than 10 minutes - let deleted = cleanup_old_events(&db, 10).await.expect("Should cleanup events"); + let deleted = cleanup_old_events(&db, 10) + .await + .expect("Should cleanup events"); assert!(deleted >= 1, "Should delete at least 1 old event"); // Verify old event is gone @@ -167,13 +194,18 @@ async fn test_trigger_notify_config_change(db: Pool) { .await .expect("Failed to insert config"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let config_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_config_change" && e.payload == "test_config_trigger") .collect(); - assert!(!config_events.is_empty(), "Should have notify_config_change event"); + assert!( + !config_events.is_empty(), + "Should have notify_config_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -190,13 +222,18 @@ async fn test_trigger_notify_global_setting_change_insert(db: Pool) { .await .expect("Failed to insert global setting"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let setting_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name) .collect(); - assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on insert"); + assert!( + !setting_events.is_empty(), + "Should have notify_global_setting_change event on insert" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -220,13 +257,18 @@ async fn test_trigger_notify_global_setting_change_update(db: Pool) { .await .expect("Failed to update global setting"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let setting_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name) .collect(); - assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on update"); + assert!( + !setting_events.is_empty(), + "Should have notify_global_setting_change event on update" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -250,13 +292,18 @@ async fn test_trigger_notify_global_setting_change_delete(db: Pool) { .await .expect("Failed to delete global setting"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let setting_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name) .collect(); - assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on delete"); + assert!( + !setting_events.is_empty(), + "Should have notify_global_setting_change event on delete" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -272,13 +319,18 @@ async fn test_trigger_notify_workspace_envs_change(db: Pool) { .await .expect("Failed to insert workspace env"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let env_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_workspace_envs_change" && e.payload == "test-workspace") .collect(); - assert!(!env_events.is_empty(), "Should have notify_workspace_envs_change event"); + assert!( + !env_events.is_empty(), + "Should have notify_workspace_envs_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -294,44 +346,57 @@ async fn test_trigger_notify_workspace_key_change(db: Pool) { .await .expect("Failed to insert workspace key"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let key_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_workspace_key_change" && e.payload == "test-workspace") .collect(); - assert!(!key_events.is_empty(), "Should have notify_workspace_key_change event"); + assert!( + !key_events.is_empty(), + "Should have notify_workspace_key_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_trigger_notify_token_invalidation(db: Pool) { - // First insert a session token + // First insert a session token with token_hash and token_prefix let token = format!("test_token_{}", uuid::Uuid::new_v4()); + let token_hash = windmill_common::utils::calculate_hash(&token); + let token_prefix = &token[..10]; sqlx::query( - "INSERT INTO token (token, label, email, workspace_id, owner, expiration) - VALUES ($1, 'session', 'test@test.com', 'test-workspace', 'test-user', now() + interval '1 hour')", + "INSERT INTO token (token_hash, token_prefix, label, email, workspace_id, owner, expiration) + VALUES ($1, $2, 'session', 'test@test.com', 'test-workspace', 'test-user', now() + interval '1 hour')", ) - .bind(&token) + .bind(&token_hash) + .bind(token_prefix) .execute(&db) .await .expect("Failed to insert token"); let before_id = get_latest_event_id(&db).await.unwrap(); - // Delete the token (should trigger notification) - sqlx::query("DELETE FROM token WHERE token = $1") - .bind(&token) + // Delete the token (should trigger notification with prefix) + sqlx::query("DELETE FROM token WHERE token_hash = $1") + .bind(&token_hash) .execute(&db) .await .expect("Failed to delete token"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let token_events: Vec<_> = events .iter() - .filter(|e| e.channel == "notify_token_invalidation" && e.payload == token) + .filter(|e| e.channel == "notify_token_invalidation" && e.payload == token_prefix) .collect(); - assert!(!token_events.is_empty(), "Should have notify_token_invalidation event"); + assert!( + !token_events.is_empty(), + "Should have notify_token_invalidation event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -344,13 +409,18 @@ async fn test_trigger_notify_webhook_change(db: Pool) { .await .expect("Failed to update webhook"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let webhook_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_webhook_change" && e.payload == "test-workspace") .collect(); - assert!(!webhook_events.is_empty(), "Should have notify_webhook_change event"); + assert!( + !webhook_events.is_empty(), + "Should have notify_webhook_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -363,13 +433,18 @@ async fn test_trigger_notify_workspace_premium_change(db: Pool) { .await .expect("Failed to update workspace premium"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let premium_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_workspace_premium_change" && e.payload == "test-workspace") .collect(); - assert!(!premium_events.is_empty(), "Should have notify_workspace_premium_change event"); + assert!( + !premium_events.is_empty(), + "Should have notify_workspace_premium_change event" + ); } // ============================================================================ @@ -392,14 +467,19 @@ async fn test_trigger_notify_http_trigger_change(db: Pool) { .await .expect("Failed to insert HTTP trigger"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let http_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_http_trigger_change") .filter(|e| e.payload.contains("test-workspace") && e.payload.contains(&trigger_path)) .collect(); - assert!(!http_events.is_empty(), "Should have notify_http_trigger_change event"); + assert!( + !http_events.is_empty(), + "Should have notify_http_trigger_change event" + ); } // ============================================================================ @@ -431,19 +511,27 @@ async fn test_trigger_notify_runnable_version_change_script(db: Pool) .await .expect("Failed to update script lock"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let script_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_runnable_version_change") .filter(|e| e.payload.contains("test-workspace") && e.payload.contains("script")) .collect(); - assert!(!script_events.is_empty(), "Should have notify_runnable_version_change event for script"); + assert!( + !script_events.is_empty(), + "Should have notify_runnable_version_change event for script" + ); // Verify payload format: workspace_id:source_type:path:kind let parts: Vec<&str> = script_events[0].payload.split(':').collect(); assert!(parts.len() >= 4, "Payload should have at least 4 parts"); - assert_eq!(parts[0], "test-workspace", "First part should be workspace_id"); + assert_eq!( + parts[0], "test-workspace", + "First part should be workspace_id" + ); assert_eq!(parts[1], "script", "Second part should be 'script'"); } @@ -472,19 +560,27 @@ async fn test_trigger_notify_runnable_version_change_flow(db: Pool) { .await .expect("Failed to update flow versions"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let flow_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_runnable_version_change") .filter(|e| e.payload.contains("test-workspace") && e.payload.contains("flow")) .collect(); - assert!(!flow_events.is_empty(), "Should have notify_runnable_version_change event for flow"); + assert!( + !flow_events.is_empty(), + "Should have notify_runnable_version_change event for flow" + ); // Verify payload format let parts: Vec<&str> = flow_events[0].payload.split(':').collect(); assert!(parts.len() >= 4, "Payload should have at least 4 parts"); - assert_eq!(parts[0], "test-workspace", "First part should be workspace_id"); + assert_eq!( + parts[0], "test-workspace", + "First part should be workspace_id" + ); assert_eq!(parts[1], "flow", "Second part should be 'flow'"); } @@ -521,13 +617,16 @@ async fn test_concurrent_event_insertion(db: Pool) { handle.await.expect("Task should complete"); } - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); - let concurrent_events: Vec<_> = events - .iter() - .filter(|e| e.channel == channel) - .collect(); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); + let concurrent_events: Vec<_> = events.iter().filter(|e| e.channel == channel).collect(); - assert_eq!(concurrent_events.len(), 10, "Should have all 10 concurrent events"); + assert_eq!( + concurrent_events.len(), + 10, + "Should have all 10 concurrent events" + ); // Verify all events have unique IDs let ids: std::collections::HashSet = concurrent_events.iter().map(|e| e.id).collect(); @@ -571,18 +670,45 @@ async fn test_polling_isolation(db: Pool) { .expect("Failed to insert event"); // Two different "consumers" polling from different points - let events_from_baseline = poll_notify_events(&db, baseline_id).await.expect("Should poll events"); - let events_from_id1 = poll_notify_events(&db, id1).await.expect("Should poll events"); - let events_from_id2 = poll_notify_events(&db, id2).await.expect("Should poll events"); + let events_from_baseline = poll_notify_events(&db, baseline_id) + .await + .expect("Should poll events"); + let events_from_id1 = poll_notify_events(&db, id1) + .await + .expect("Should poll events"); + let events_from_id2 = poll_notify_events(&db, id2) + .await + .expect("Should poll events"); // Filter to our test events - let from_baseline: Vec<_> = events_from_baseline.iter().filter(|e| e.channel == channel).collect(); - let from_id1: Vec<_> = events_from_id1.iter().filter(|e| e.channel == channel).collect(); - let from_id2: Vec<_> = events_from_id2.iter().filter(|e| e.channel == channel).collect(); + let from_baseline: Vec<_> = events_from_baseline + .iter() + .filter(|e| e.channel == channel) + .collect(); + let from_id1: Vec<_> = events_from_id1 + .iter() + .filter(|e| e.channel == channel) + .collect(); + let from_id2: Vec<_> = events_from_id2 + .iter() + .filter(|e| e.channel == channel) + .collect(); - assert_eq!(from_baseline.len(), 3, "Polling from baseline should include all 3 events"); - assert_eq!(from_id1.len(), 2, "Polling from id1 should include id2 and id3"); - assert_eq!(from_id2.len(), 1, "Polling from id2 should include only id3"); + assert_eq!( + from_baseline.len(), + 3, + "Polling from baseline should include all 3 events" + ); + assert_eq!( + from_id1.len(), + 2, + "Polling from id1 should include id2 and id3" + ); + assert_eq!( + from_id2.len(), + 1, + "Polling from id2 should include only id3" + ); } // ============================================================================ @@ -595,14 +721,23 @@ async fn test_empty_payload(db: Pool) { insert_test_event(&db, "test_empty_payload", "").await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let empty_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_empty_payload") .collect(); - assert_eq!(empty_events.len(), 1, "Should have event with empty payload"); - assert_eq!(empty_events[0].payload, "", "Payload should be empty string"); + assert_eq!( + empty_events.len(), + 1, + "Should have event with empty payload" + ); + assert_eq!( + empty_events[0].payload, "", + "Payload should be empty string" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -613,14 +748,24 @@ async fn test_large_payload(db: Pool) { let large_payload = "x".repeat(1024); insert_test_event(&db, "test_large_payload", &large_payload).await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let large_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_large_payload") .collect(); - assert_eq!(large_events.len(), 1, "Should have event with large payload"); - assert_eq!(large_events[0].payload.len(), 1024, "Payload should be preserved"); + assert_eq!( + large_events.len(), + 1, + "Should have event with large payload" + ); + assert_eq!( + large_events[0].payload.len(), + 1024, + "Payload should be preserved" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -630,14 +775,23 @@ async fn test_special_characters_in_payload(db: Pool) { let special_payload = r#"{"key": "value with \"quotes\" and 'apostrophes'", "unicode": "日本語", "newline": "line1\nline2"}"#; insert_test_event(&db, "test_special_chars", special_payload).await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let special_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_special_chars") .collect(); - assert_eq!(special_events.len(), 1, "Should have event with special characters"); - assert_eq!(special_events[0].payload, special_payload, "Special characters should be preserved"); + assert_eq!( + special_events.len(), + 1, + "Should have event with special characters" + ); + assert_eq!( + special_events[0].payload, special_payload, + "Special characters should be preserved" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -664,7 +818,9 @@ async fn test_cleanup_with_no_old_events(db: Pool) { assert_eq!(before_count, 2, "Should have 2 recent events"); // Cleanup old events (none of our events should be deleted since they're recent) - let _deleted = cleanup_old_events(&db, 10).await.expect("Should cleanup events"); + let _deleted = cleanup_old_events(&db, 10) + .await + .expect("Should cleanup events"); let after_count = count_events_for_channel(&db, &channel).await; assert_eq!(after_count, 2, "Recent events should not be deleted"); @@ -732,7 +888,11 @@ impl ServerProcess { } fn logs_contain(&self, needle: &str) -> bool { - self.log_lines.lock().unwrap().iter().any(|l| l.contains(needle)) + self.log_lines + .lock() + .unwrap() + .iter() + .any(|l| l.contains(needle)) } fn dump_logs(&self) -> String { @@ -783,12 +943,17 @@ async fn test_two_server_processes_both_receive_event() { let mut server_b = ServerProcess::start(19200, &db_url); // Wait for both servers to be ready - let (ready_a, ready_b) = tokio::join!( - wait_for_server(19100, 30), - wait_for_server(19200, 30), + let (ready_a, ready_b) = tokio::join!(wait_for_server(19100, 30), wait_for_server(19200, 30),); + assert!( + ready_a, + "Server A (port 19100) failed to start. Logs:\n{}", + server_a.dump_logs() + ); + assert!( + ready_b, + "Server B (port 19200) failed to start. Logs:\n{}", + server_b.dump_logs() ); - assert!(ready_a, "Server A (port 19100) failed to start. Logs:\n{}", server_a.dump_logs()); - assert!(ready_b, "Server B (port 19200) failed to start. Logs:\n{}", server_b.dump_logs()); // Give servers a moment to complete their first poll cycle tokio::time::sleep(std::time::Duration::from_secs(2)).await; diff --git a/backend/windmill-native-triggers/src/google/external.rs b/backend/windmill-native-triggers/src/google/external.rs index 756090fa02..f68706fb2f 100644 --- a/backend/windmill-native-triggers/src/google/external.rs +++ b/backend/windmill-native-triggers/src/google/external.rs @@ -11,7 +11,7 @@ use windmill_common::{ use windmill_queue::PushArgsOwned; use crate::{ - generate_webhook_service_url, get_token_by_prefix, + generate_webhook_service_url, rotate_webhook_token, sync::{SyncAction, SyncError, TriggerSyncInfo}, update_native_trigger_error, update_native_trigger_service_config, External, NativeTrigger, NativeTriggerData, ServiceName, @@ -309,14 +309,16 @@ impl Google { } /// Renew an expiring Google watch channel. - /// Stops the old channel and creates a new one with the same channel ID. - /// Returns the updated service_config with new expiration. + /// Rotates the webhook token (creating a new one with the same label), + /// stops the old channel and creates a new one with the same channel ID. + /// Returns (new_service_config, new_plaintext_token, old_token_hash). + /// Callers should delete old_token_hash after successfully updating the trigger. pub async fn renew_channel( &self, w_id: &str, trigger: &NativeTrigger, db: &DB, - ) -> Result { + ) -> Result<(serde_json::Value, String, String)> { let config: GoogleServiceConfig = trigger .service_config .as_ref() @@ -324,10 +326,15 @@ impl Google { .transpose()? .ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?; - let webhook_token = get_token_by_prefix(db, &trigger.webhook_token_prefix) - .await? - .ok_or_else(|| Error::InternalErr("Webhook token not found".to_string()))?; - + let rotated = match rotate_webhook_token(db, &trigger.webhook_token_hash).await? { + Some(r) => r, + None => { + return Err(Error::InternalErr(format!( + "Cannot renew channel {}: webhook token no longer exists and no user context to create a fresh one", + trigger.external_id + ))); + } + }; let base_url = &*BASE_URL.read().await; // Reuse the same channel ID so external_id stays permanent let channel_id = trigger.external_id.clone(); @@ -338,7 +345,7 @@ impl Google { trigger.is_flow, Some(&channel_id), ServiceName::Google, - &webhook_token, + &rotated.new_token, ); tracing::info!( @@ -403,8 +410,9 @@ impl Google { new_config.google_resource_id = Some(resp.resource_id); new_config.expiration = Some(resp.expiration); - serde_json::to_value(&new_config) - .map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e))) + let config_value = serde_json::to_value(&new_config) + .map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e)))?; + Ok((config_value, rotated.new_token, rotated.old_token_hash)) } } @@ -461,17 +469,25 @@ async fn renew_expiring_channels( ); match handler.renew_channel(workspace_id, trigger, db).await { - Ok(new_config) => { + Ok((new_config, new_token, old_token_hash)) => { match update_native_trigger_service_config( db, workspace_id, ServiceName::Google, &trigger.external_id, &new_config, + Some(&new_token), ) .await { Ok(()) => { + // Trigger updated — clean up old token (best-effort) + if let Err(e) = crate::delete_token_by_hash(db, &old_token_hash).await { + tracing::warn!( + "Failed to delete old webhook token after channel renewal for {}: {}", + trigger.external_id, e + ); + } tracing::info!( "Renewed Google channel {} for '{}'", trigger.external_id, diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 9384bde8b6..b66a8ae3e0 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -1,6 +1,6 @@ use crate::{ - decrypt_oauth_data, delete_native_trigger, delete_token_by_prefix, get_native_trigger, - get_token_by_prefix, list_native_triggers, store_native_trigger, update_native_trigger_error, + decrypt_oauth_data, delete_native_trigger, delete_token_by_hash, get_native_trigger, + list_native_triggers, rotate_webhook_token, store_native_trigger, update_native_trigger_error, External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName, }; use axum::{ @@ -234,31 +234,48 @@ async fn update_native_trigger_handler( let runnable_changed = existing.script_path != data.script_path || existing.is_flow != data.is_flow; - let webhook_token = match get_token_by_prefix(&db, &existing.webhook_token_prefix).await? { - Some(token) if !runnable_changed => token, - existing_token => { - if let Some(_) = existing_token { - delete_token_by_prefix(&db, &existing.webhook_token_prefix).await?; - } else { - tracing::warn!( - "Webhook token not found for trigger {} (prefix: {}), recreating token", - external_id, - existing.webhook_token_prefix - ); + // Track old token hash so we can clean it up after everything succeeds + let mut old_token_hash_to_delete: Option = None; + + let webhook_token = if runnable_changed { + // Scopes change when the runnable changes — delete old, create fresh token + old_token_hash_to_delete = Some(existing.webhook_token_hash.clone()); + let token = new_webhook_token( + &mut *tx, + &db, + &authed, + &data.script_path, + data.is_flow, + &workspace_id, + service_name, + ) + .await?; + tx.commit().await?; + tx = user_db.begin(&authed).await?; + token + } else { + // Same runnable — rotate the token keeping the same label + match rotate_webhook_token(&db, &existing.webhook_token_hash).await? { + Some(rotated) => { + old_token_hash_to_delete = Some(rotated.old_token_hash); + rotated.new_token + } + None => { + // Old token gone — create a fresh one + let token = new_webhook_token( + &mut *tx, + &db, + &authed, + &data.script_path, + data.is_flow, + &workspace_id, + service_name, + ) + .await?; + tx.commit().await?; + tx = user_db.begin(&authed).await?; + token } - let token = new_webhook_token( - &mut *tx, - &db, - &authed, - &data.script_path, - data.is_flow, - &workspace_id, - service_name, - ) - .await?; - tx.commit().await?; - tx = user_db.begin(&authed).await?; - token } }; @@ -303,6 +320,16 @@ async fn update_native_trigger_handler( tx.commit().await?; + // Everything succeeded — clean up old token (best-effort) + if let Some(old_hash) = old_token_hash_to_delete { + if let Err(e) = delete_token_by_hash(&db, &old_hash).await { + tracing::warn!( + "Failed to delete old webhook token after trigger update: {}", + e + ); + } + } + Ok(format!("Native trigger updated")) } @@ -428,12 +455,12 @@ async fn delete_native_trigger_handler( return Err(Error::NotFound(format!("Native trigger not found"))); } - // Delete the webhook token using its prefix - if !delete_token_by_prefix(&db, &existing.webhook_token_prefix).await? { + // Delete the webhook token using its hash + if !delete_token_by_hash(&db, &existing.webhook_token_hash).await? { tracing::warn!( - "Webhook token not found when deleting trigger {} (prefix: {})", + "Webhook token not found when deleting trigger {} (hash: {})", external_id, - existing.webhook_token_prefix + existing.webhook_token_hash ); } diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 416cf62d86..25234ff3f0 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -190,7 +190,7 @@ pub struct NativeTrigger { pub service_name: ServiceName, pub script_path: String, pub is_flow: bool, - pub webhook_token_prefix: String, + pub webhook_token_hash: String, pub service_config: Option, pub error: Option, pub created_at: DateTime, @@ -731,41 +731,85 @@ async fn update_oauth_token_resource( } } -/// Look up the full token from the token table using its prefix -pub async fn get_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>>( - db: E, - token_prefix: &str, -) -> Result> { - let token = sqlx::query_scalar!( - r#" - SELECT token as "token!" - FROM token - WHERE token LIKE concat($1::text, '%') - LIMIT 1 - "#, - token_prefix +/// Create a new webhook token that keeps the same label as the old one. +/// The old token is **not** deleted — callers must call `delete_token_by_hash` +/// on `old_token_hash` after the trigger row has been successfully updated. +/// This ensures the trigger keeps working if the external service call or +/// subsequent DB update fails. +/// +/// Returns `Ok(None)` if the old token no longer exists (e.g. manually deleted by user). +/// In that case, `renew_channel` returns an error which `renew_expiring_channels` writes +/// to the trigger's `error` column — visible in the UI so the user can re-create the trigger. +pub async fn rotate_webhook_token(db: &DB, old_token_hash: &str) -> Result> { + use windmill_common::auth::{hash_token, TOKEN_PREFIX_LEN}; + use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; + use windmill_common::utils::rd_string; + + let old = match sqlx::query!( + "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1", + old_token_hash ) .fetch_optional(db) - .await?; + .await? + { + Some(row) => row, + None => { + tracing::warn!( + "Webhook token not found for hash {}, caller should create a fresh token", + old_token_hash + ); + return Ok(None); + } + }; - Ok(token) -} + let new_token = rd_string(32); + let new_hash = hash_token(&new_token); + let new_prefix = new_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&new_token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&new_token) + }; -/// Delete a token from the token table using its prefix -pub async fn delete_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>>( - db: E, - token_prefix: &str, -) -> Result { - let deleted = sqlx::query!( - r#" - DELETE FROM token - WHERE token LIKE concat($1::text, '%') - "#, - token_prefix + sqlx::query!( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes, workspace_id, owner, expiration) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + new_hash, + new_prefix, + plaintext as Option<&str>, + old.email, + old.label, + old.super_admin, + old.scopes.as_deref(), + old.workspace_id, + old.owner, + old.expiration, ) .execute(db) - .await? - .rows_affected(); + .await?; + + Ok(Some(RotatedToken { + new_token, + old_token_hash: old_token_hash.to_string(), + })) +} + +pub struct RotatedToken { + pub new_token: String, + /// Hash of the old token — callers should delete this after the + /// trigger row has been successfully updated to point at the new token. + pub old_token_hash: String, +} + +/// Delete a token from the token table using its hash (exact match). +pub async fn delete_token_by_hash<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + token_hash: &str, +) -> Result { + let deleted = sqlx::query!("DELETE FROM token WHERE token_hash = $1", token_hash) + .execute(db) + .await? + .rows_affected(); Ok(deleted > 0) } @@ -778,8 +822,9 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> config: &NativeTriggerConfig, service_config: C, ) -> Result<()> { - // Store only the first 10 characters of the webhook token as a prefix - let webhook_token_prefix: String = config.webhook_token.chars().take(10).collect(); + use windmill_common::auth::hash_token; + + let webhook_token_hash = hash_token(&config.webhook_token); sqlx::query!( r#" @@ -789,20 +834,20 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> service_name, script_path, is_flow, - webhook_token_prefix, + webhook_token_hash, service_config ) VALUES ( $1, $2, $3, $4, $5, $6, $7 ) ON CONFLICT (external_id, workspace_id, service_name) - DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_prefix = $6, service_config = $7, error = NULL, updated_at = NOW() + DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW() "#, external_id, workspace_id, service_name as ServiceName, config.script_path, config.is_flow, - webhook_token_prefix, + webhook_token_hash, sqlx::types::Json(service_config) as _, ) .execute(db) @@ -819,13 +864,14 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres config: &NativeTriggerConfig, service_config: Option<&RawValue>, ) -> Result<()> { - // Store only the first 10 characters of the webhook token as a prefix - let webhook_token_prefix: String = config.webhook_token.chars().take(10).collect(); + use windmill_common::auth::hash_token; + + let webhook_token_hash = hash_token(&config.webhook_token); sqlx::query!( r#" UPDATE native_trigger - SET script_path = $1, is_flow = $2, webhook_token_prefix = $3, service_config = $4, error = NULL, updated_at = NOW() + SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW() WHERE workspace_id = $5 AND service_name = $6 @@ -833,7 +879,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres "#, config.script_path, config.is_flow, - webhook_token_prefix, + webhook_token_hash, service_config.map(sqlx::types::Json) as _, workspace_id, service_name as ServiceName, @@ -884,7 +930,7 @@ pub async fn get_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>( service_name AS "service_name!: ServiceName", script_path, is_flow, - webhook_token_prefix, + webhook_token_hash, service_config, error, created_at, @@ -922,7 +968,7 @@ pub async fn get_native_trigger_by_script<'c, E: sqlx::Executor<'c, Database = P service_name AS "service_name!: ServiceName", script_path, is_flow, - webhook_token_prefix, + webhook_token_hash, service_config, error, created_at, @@ -968,7 +1014,7 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> nt.service_name AS "service_name!: ServiceName", nt.script_path, nt.is_flow, - nt.webhook_token_prefix, + nt.webhook_token_hash, nt.service_config, nt.error, nt.created_at, @@ -1045,11 +1091,16 @@ pub async fn update_native_trigger_service_config< service_name: ServiceName, external_id: &str, service_config: &serde_json::Value, + new_webhook_token: Option<&str>, ) -> Result<()> { + let new_hash = new_webhook_token.map(windmill_common::auth::hash_token); + sqlx::query!( r#" UPDATE native_trigger - SET service_config = $1, updated_at = NOW() + SET service_config = $1, + webhook_token_hash = COALESCE($5, webhook_token_hash), + updated_at = NOW() WHERE workspace_id = $2 AND service_name = $3 @@ -1059,6 +1110,7 @@ pub async fn update_native_trigger_service_config< workspace_id, service_name as ServiceName, external_id, + new_hash.as_deref(), ) .execute(db) .await?; diff --git a/backend/windmill-native-triggers/src/sync.rs b/backend/windmill-native-triggers/src/sync.rs index 873ba22557..292d00de2a 100644 --- a/backend/windmill-native-triggers/src/sync.rs +++ b/backend/windmill-native-triggers/src/sync.rs @@ -390,6 +390,7 @@ pub async fn reconcile_with_external_state( service_name, &trigger.external_id, external_service_config, + None, ) .await { diff --git a/backend/windmill-native-triggers/src/workspace_integrations.rs b/backend/windmill-native-triggers/src/workspace_integrations.rs index 3d3db5eea5..87d40d5b05 100644 --- a/backend/windmill-native-triggers/src/workspace_integrations.rs +++ b/backend/windmill-native-triggers/src/workspace_integrations.rs @@ -34,8 +34,8 @@ use windmill_api_auth::ApiAuthed; #[cfg(feature = "native_trigger")] use crate::{ - decrypt_oauth_data, delete_token_by_prefix, delete_workspace_integration, - nextcloud::OcsResponse, resolve_endpoint, store_workspace_integration, ServiceName, + decrypt_oauth_data, delete_token_by_hash, delete_workspace_integration, nextcloud::OcsResponse, + resolve_endpoint, store_workspace_integration, ServiceName, }; #[cfg(feature = "native_trigger")] @@ -253,7 +253,7 @@ async fn fetch_nextcloud_user_id(base_url: &str, access_token: &str) -> anyhow:: #[cfg(feature = "native_trigger")] async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: ServiceName) { let triggers = sqlx::query!( - "SELECT external_id, webhook_token_prefix FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", workspace_id, service_name as ServiceName ) @@ -303,10 +303,10 @@ async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: // Delete all associated webhook tokens for trigger in &triggers { - if let Err(e) = delete_token_by_prefix(db, &trigger.webhook_token_prefix).await { + if let Err(e) = delete_token_by_hash(db, &trigger.webhook_token_hash).await { tracing::error!( - "Failed to delete webhook token with prefix {}: {e}", - trigger.webhook_token_prefix + "Failed to delete webhook token with hash {}: {e}", + trigger.webhook_token_hash ); } } diff --git a/backend/windmill-queue/tests/fixtures/base.sql b/backend/windmill-queue/tests/fixtures/base.sql index 7db9918fba..412fa1029f 100644 --- a/backend/windmill-queue/tests/fixtures/base.sql +++ b/backend/windmill-queue/tests/fixtures/base.sql @@ -33,9 +33,9 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; From 18b3528ba4188721d918fd47f0f86a6b41209453 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Mon, 16 Mar 2026 21:19:46 -0400 Subject: [PATCH 71/76] feat: instance groups instance-level role support (#8404) * [ee] feat: instance groups instance-level role support Add ability to assign instance-level roles (superadmin/devops) to instance groups. Group members automatically receive the role with proper precedence: manual elevated roles always win, superadmin > devops. - Migration: add instance_role to instance_group, role_source to password - Role propagation on all mutation paths (add/remove/update/delete/import) - SCIM sync integration (companion PR: windmill-ee-private#463) - Frontend: role toggle in group editor, role column in tables, role source indicator in superadmin settings Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to 278a3887f759f9d1146554baa0765518d5bc70f2 This commit updates the EE repository reference after PR #463 was merged in windmill-ee-private. Previous ee-repo-ref: b407fe4604153d09ff223e11d43c2df83a1de5d0 New ee-repo-ref: 278a3887f759f9d1146554baa0765518d5bc70f2 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...be922e1463d2046753cf80377369a90013ab.json} | 10 +- ...5d3488e0969f2c334ab7c0bfcd5235e3dd82.json} | 12 +- ...1303a7756f654d1a2c04bd47c0f2d1122155.json} | 12 +- ...56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json} | 4 +- ...5d37d14d51d5c95776e2d5491b5c5ab3e25b7.json | 22 ++ ...ea8be1f62e4607fcd8f401701ab66f6d20cae.json | 28 ++ ...6fedb0f3f0040844c3774145e8f1f4c023ce.json} | 10 +- ...b1e7ce2bb160270b59c0f359b7952453b2b9.json} | 10 +- ...8e719ec455acd53e30984087d5c3a4a03cd25.json | 34 +++ ...70226ceb1b94a53dd540cdc27a0e1060aff12.json | 34 +++ ...fa81e8134f25b1dfcedb765a701f0dae8564.json} | 5 +- ...37dbe2e92de1b45dfc9f2656fea9d08b354a0.json | 16 ++ ...973bd9d2d7818320ae4d6c02d68a29298e63.json} | 4 +- ...f60634f06fa6a2b5c65f654f4da87e3724c25.json | 20 ++ ...3e26ec51302e8916e3b186123c024546ff4a.json} | 12 +- ...0cb549a34b96554ae1872355b90304f5dcb76.json | 4 +- ...1d4a0dc97741055d83d4102395f1a05824d6b.json | 22 ++ ...120eb8e28c823f9e04fb54b7a545a353495c1.json | 22 ++ ...9ee04f062c989bd1c7abfc0098b408a216cc.json} | 10 +- backend/ee-repo-ref.txt | 2 +- ...0260316000000_instance_group_role.down.sql | 2 + .../20260316000000_instance_group_role.up.sql | 9 + backend/windmill-api-groups/src/groups.rs | 272 +++++++++++++++++- backend/windmill-api-users/src/users.rs | 80 +++++- backend/windmill-api/openapi.yaml | 28 +- .../lib/components/InstanceGroupEditor.svelte | 121 ++++++-- .../components/SuperadminSettingsInner.svelte | 141 +++++---- .../(root)/(logged)/groups/+page.svelte | 20 +- .../(logged)/instance_groups/+page.svelte | 4 +- 29 files changed, 841 insertions(+), 129 deletions(-) rename backend/.sqlx/{query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json => query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json} (82%) rename backend/.sqlx/{query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json => query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json} (57%) rename backend/.sqlx/{query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json => query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json} (58%) rename backend/.sqlx/{query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json => query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json} (50%) create mode 100644 backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json create mode 100644 backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json rename backend/.sqlx/{query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json => query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json} (81%) rename backend/.sqlx/{query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json => query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json} (84%) create mode 100644 backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json create mode 100644 backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json rename backend/.sqlx/{query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json => query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json} (63%) create mode 100644 backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json rename backend/.sqlx/{query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json => query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json} (51%) create mode 100644 backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json rename backend/.sqlx/{query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json => query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json} (58%) create mode 100644 backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json create mode 100644 backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json rename backend/.sqlx/{query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json => query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json} (74%) create mode 100644 backend/migrations/20260316000000_instance_group_role.down.sql create mode 100644 backend/migrations/20260316000000_instance_group_role.up.sql diff --git a/backend/.sqlx/query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json b/backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json similarity index 82% rename from backend/.sqlx/query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json rename to backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json index 7857ca1b4e..53a3863587 100644 --- a/backend/.sqlx/query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json +++ b/backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", + "query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "first_time_user", "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" } ], "parameters": { @@ -70,8 +75,9 @@ true, true, null, + false, false ] }, - "hash": "b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b" + "hash": "05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab" } diff --git a/backend/.sqlx/query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json b/backend/.sqlx/query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json similarity index 57% rename from backend/.sqlx/query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json rename to backend/.sqlx/query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json index b703a00e96..39104e0251 100644 --- a/backend/.sqlx/query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json +++ b/backend/.sqlx/query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary, instance_role", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "emails", "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -25,8 +30,9 @@ "nullable": [ false, true, - null + null, + true ] }, - "hash": "a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1" + "hash": "0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82" } diff --git a/backend/.sqlx/query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json b/backend/.sqlx/query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json similarity index 58% rename from backend/.sqlx/query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json rename to backend/.sqlx/query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json index 98bafc734c..b15885016f 100644 --- a/backend/.sqlx/query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json +++ b/backend/.sqlx/query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, instance_role", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "emails", "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -25,8 +30,9 @@ "nullable": [ false, true, - null + null, + true ] }, - "hash": "10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4" + "hash": "0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155" } diff --git a/backend/.sqlx/query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json b/backend/.sqlx/query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json similarity index 50% rename from backend/.sqlx/query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json rename to backend/.sqlx/query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json index 56edad4b2c..df0c67b88e 100644 --- a/backend/.sqlx/query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json +++ b/backend/.sqlx/query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE password SET devops = $1 WHERE email = $2", + "query": "UPDATE password SET super_admin = $1, role_source = 'manual' WHERE email = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824" + "hash": "11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e" } diff --git a/backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json b/backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json new file mode 100644 index 0000000000..49c3763688 --- /dev/null +++ b/backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ig.instance_role FROM email_to_igroup eig\n JOIN instance_group ig ON ig.name = eig.igroup\n WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "instance_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7" +} diff --git a/backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json b/backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json new file mode 100644 index 0000000000..db78e7dc89 --- /dev/null +++ b/backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT super_admin, devops FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "devops", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae" +} diff --git a/backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json b/backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json similarity index 81% rename from backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json rename to backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json index 9eb47caf8a..6d2382f494 100644 --- a/backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json +++ b/backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", + "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "first_time_user", "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" } ], "parameters": { @@ -70,8 +75,9 @@ true, true, true, + false, false ] }, - "hash": "9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59" + "hash": "60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce" } diff --git a/backend/.sqlx/query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json b/backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json similarity index 84% rename from backend/.sqlx/query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json rename to backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json index 9474727ace..2ccf7bfdc8 100644 --- a/backend/.sqlx/query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json +++ b/backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user FROM password WHERE email = $1", + "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE email = $1", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "first_time_user", "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" } ], "parameters": { @@ -69,8 +74,9 @@ true, true, null, + false, false ] }, - "hash": "37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330" + "hash": "65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9" } diff --git a/backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json b/backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json new file mode 100644 index 0000000000..53999f934a --- /dev/null +++ b/backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n ws.workspace_id,\n w.name as workspace_name,\n ws.auto_invite->'instance_groups_roles'->$1 as role\n FROM workspace_settings ws\n INNER JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false\n WHERE ws.auto_invite->'instance_groups' ? $1\n ORDER BY ws.workspace_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "workspace_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "role", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25" +} diff --git a/backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json b/backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json new file mode 100644 index 0000000000..90ee071042 --- /dev/null +++ b/backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT super_admin, devops, role_source FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "devops", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "role_source", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12" +} diff --git a/backend/.sqlx/query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json b/backend/.sqlx/query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json similarity index 63% rename from backend/.sqlx/query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json rename to backend/.sqlx/query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json index b62dba4bbe..84833c9718 100644 --- a/backend/.sqlx/query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json +++ b/backend/.sqlx/query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id) VALUES ($1, $2, $3, $4, $5)", + "query": "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id, instance_role) VALUES ($1, $2, $3, $4, $5, $6)", "describe": { "columns": [], "parameters": { @@ -9,10 +9,11 @@ "Varchar", "Varchar", "Varchar", + "Varchar", "Varchar" ] }, "nullable": [] }, - "hash": "234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7" + "hash": "88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564" } diff --git a/backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json b/backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json new file mode 100644 index 0000000000..c711fdfc59 --- /dev/null +++ b/backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE password SET super_admin = $1, devops = $2, role_source = 'instance_group' WHERE email = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0" +} diff --git a/backend/.sqlx/query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json b/backend/.sqlx/query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json similarity index 51% rename from backend/.sqlx/query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json rename to backend/.sqlx/query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json index a9df312f5e..f03e49e330 100644 --- a/backend/.sqlx/query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json +++ b/backend/.sqlx/query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE password SET super_admin = $1 WHERE email = $2", + "query": "UPDATE password SET devops = $1, role_source = 'manual' WHERE email = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c" + "hash": "96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63" } diff --git a/backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json b/backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json new file mode 100644 index 0000000000..519fb4f367 --- /dev/null +++ b/backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM password\n WHERE role_source = 'instance_group' AND (super_admin = true OR devops = true)\n AND email NOT IN (\n SELECT eig.email FROM email_to_igroup eig\n JOIN instance_group ig ON ig.name = eig.igroup\n WHERE ig.instance_role IS NOT NULL\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25" +} diff --git a/backend/.sqlx/query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json b/backend/.sqlx/query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json similarity index 58% rename from backend/.sqlx/query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json rename to backend/.sqlx/query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json index 8e10da3f7b..eb34d76f61 100644 --- a/backend/.sqlx/query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json +++ b/backend/.sqlx/query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name, instance_role", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "emails", "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -27,8 +32,9 @@ "nullable": [ false, true, - null + null, + true ] }, - "hash": "ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478" + "hash": "9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a" } diff --git a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json index 54e94cfb8f..99269c9851 100644 --- a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json +++ b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json @@ -18,8 +18,8 @@ "Left": [] }, "nullable": [ - false, - true + true, + false ] }, "hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76" diff --git a/backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json b/backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json new file mode 100644 index 0000000000..7bf373db2d --- /dev/null +++ b/backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT instance_role FROM instance_group WHERE name = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "instance_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b" +} diff --git a/backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json b/backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json new file mode 100644 index 0000000000..a73a1a8444 --- /dev/null +++ b/backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ig.instance_role FROM email_to_igroup eig\n JOIN instance_group ig ON ig.name = eig.igroup\n WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "instance_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1" +} diff --git a/backend/.sqlx/query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json b/backend/.sqlx/query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json similarity index 74% rename from backend/.sqlx/query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json rename to backend/.sqlx/query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json index ed63942f01..eee7213e44 100644 --- a/backend/.sqlx/query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json +++ b/backend/.sqlx/query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "external_id", "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -43,8 +48,9 @@ null, true, true, + true, true ] }, - "hash": "9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f" + "hash": "fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4615ab8810..648a079815 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -2d0823a471014e2bc2d898c63518323946b7474f +278a3887f759f9d1146554baa0765518d5bc70f2 diff --git a/backend/migrations/20260316000000_instance_group_role.down.sql b/backend/migrations/20260316000000_instance_group_role.down.sql new file mode 100644 index 0000000000..80af3b5006 --- /dev/null +++ b/backend/migrations/20260316000000_instance_group_role.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_group DROP COLUMN instance_role; +ALTER TABLE password DROP COLUMN role_source; diff --git a/backend/migrations/20260316000000_instance_group_role.up.sql b/backend/migrations/20260316000000_instance_group_role.up.sql new file mode 100644 index 0000000000..ef1c8ee3db --- /dev/null +++ b/backend/migrations/20260316000000_instance_group_role.up.sql @@ -0,0 +1,9 @@ +-- instance_group: add instance-level role (NULL = none, 'devops', 'superadmin') +ALTER TABLE instance_group ADD COLUMN instance_role VARCHAR(20) DEFAULT NULL; +ALTER TABLE instance_group ADD CONSTRAINT check_instance_role + CHECK (instance_role IN ('devops', 'superadmin')); + +-- password: track whether elevated role was set manually or by instance group +ALTER TABLE password ADD COLUMN role_source VARCHAR(20) NOT NULL DEFAULT 'manual'; +ALTER TABLE password ADD CONSTRAINT check_role_source + CHECK (role_source IN ('manual', 'instance_group')); diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index a68b72072d..4b07ae7a4d 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -328,9 +328,133 @@ async fn create_igroup( Ok(format!("Created group {}", normalized_name)) } +fn validate_instance_role(role: &Option) -> Result> { + match role.as_deref() { + None => Ok(None), + Some("") | Some("user") => Ok(None), + Some("devops") => Ok(Some("devops".to_string())), + Some("superadmin") => Ok(Some("superadmin".to_string())), + Some(other) => Err(Error::BadRequest(format!( + "Invalid instance_role '{}'. Must be 'devops', 'superadmin', 'user', or empty to clear", + other + ))), + } +} + +/// Compute the highest-precedence instance role from all groups a user belongs to. +/// superadmin > devops > none +pub async fn compute_effective_instance_role( + email: &str, + tx: &mut Transaction<'_, Postgres>, +) -> Result> { + let roles = sqlx::query_scalar!( + "SELECT ig.instance_role FROM email_to_igroup eig + JOIN instance_group ig ON ig.name = eig.igroup + WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + email + ) + .fetch_all(&mut **tx) + .await?; + + let mut highest: Option = None; + for role in roles.into_iter().flatten() { + match role.as_str() { + "superadmin" => return Ok(Some("superadmin".to_string())), + "devops" => highest = Some("devops".to_string()), + _ => {} + } + } + Ok(highest) +} + +/// Apply computed instance role to password table and invalidate session tokens. +/// Only applies if role_source = 'instance_group' or user has no elevated role. +pub async fn apply_instance_role( + email: &str, + role: Option<&str>, + tx: &mut Transaction<'_, Postgres>, +) -> Result<()> { + let current = sqlx::query!( + "SELECT super_admin, devops, role_source FROM password WHERE email = $1", + email + ) + .fetch_optional(&mut **tx) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(()), // user doesn't exist in password table + }; + + // Don't touch manually-set elevated roles — manual always wins + if current.role_source == "manual" && (current.super_admin || current.devops) { + return Ok(()); + } + + let (new_super_admin, new_devops) = match role { + Some("superadmin") => (true, false), + Some("devops") => (false, true), + _ => (false, false), + }; + + // Only update if something actually changed + if current.super_admin == new_super_admin && current.devops == new_devops { + return Ok(()); + } + + sqlx::query!( + "UPDATE password SET super_admin = $1, devops = $2, role_source = 'instance_group' WHERE email = $3", + new_super_admin, + new_devops, + email + ) + .execute(&mut **tx) + .await?; + + // Invalidate session tokens to force re-login with new privileges + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session'", + email + ) + .execute(&mut **tx) + .await?; + + // Update super_admin flag on non-session tokens + sqlx::query!( + "UPDATE token SET super_admin = $1 WHERE email = $2 AND label != 'session'", + new_super_admin, + email + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +/// Recompute and apply roles for all members of a given instance group. +pub async fn propagate_instance_group_roles( + group_name: &str, + tx: &mut Transaction<'_, Postgres>, +) -> Result<()> { + let members = sqlx::query_scalar!( + "SELECT email FROM email_to_igroup WHERE igroup = $1", + group_name + ) + .fetch_all(&mut **tx) + .await?; + + for email in members { + let effective_role = compute_effective_instance_role(&email, tx).await?; + apply_instance_role(&email, effective_role.as_deref(), tx).await?; + } + + Ok(()) +} + #[derive(Deserialize)] struct IGroupUpdate { new_summary: String, + instance_role: Option, } async fn update_igroup( @@ -348,17 +472,37 @@ async fn update_igroup( .await?; not_found_if_none(exists_opt, "instance_group", name.clone())?; - sqlx::query("UPDATE instance_group SET summary = $1 WHERE name = $2") + let validated_role = validate_instance_role(&igroup_update.instance_role)?; + + // Fetch old role before updating so we can detect changes + let old_role = if igroup_update.instance_role.is_some() { + sqlx::query_scalar!( + "SELECT instance_role FROM instance_group WHERE name = $1", + &name + ) + .fetch_one(&mut *tx) + .await? + } else { + None + }; + + sqlx::query("UPDATE instance_group SET summary = $1, instance_role = $2 WHERE name = $3") .bind(igroup_update.new_summary) + .bind(&validated_role) .bind(&name) .execute(&mut *tx) .await?; + // If instance_role actually changed, propagate to all group members + if igroup_update.instance_role.is_some() && old_role != validated_role { + propagate_instance_group_roles(&name, &mut tx).await?; + } + audit_log( &mut *tx, &authed, "igroup.updated", - ActionKind::Delete, + ActionKind::Update, "global", Some(&name.to_string()), None, @@ -366,7 +510,7 @@ async fn update_igroup( .await?; tx.commit().await?; - Ok(format!("Deleted group {}", name)) + Ok(format!("Updated group {}", name)) } async fn delete_igroup( @@ -376,14 +520,38 @@ async fn delete_igroup( ) -> Result { require_super_admin(&db, &authed.email).await?; let mut tx: Transaction<'_, Postgres> = db.begin().await?; - sqlx::query!("DELETE FROM instance_group WHERE name = $1", name) - .execute(&mut *tx) - .await?; + + // Fetch group's instance_role and members before deletion + let group_role = sqlx::query_scalar!( + "SELECT instance_role FROM instance_group WHERE name = $1", + &name + ) + .fetch_optional(&mut *tx) + .await? + .flatten(); + + let affected_members: Vec = if group_role.is_some() { + sqlx::query_scalar!("SELECT email FROM email_to_igroup WHERE igroup = $1", &name) + .fetch_all(&mut *tx) + .await? + } else { + vec![] + }; sqlx::query!("DELETE FROM email_to_igroup WHERE igroup = $1", name) .execute(&mut *tx) .await?; + sqlx::query!("DELETE FROM instance_group WHERE name = $1", name) + .execute(&mut *tx) + .await?; + + // Recompute roles for affected members after deletion + for email in &affected_members { + let effective_role = compute_effective_instance_role(email, &mut tx).await?; + apply_instance_role(email, effective_role.as_deref(), &mut tx).await?; + } + audit_log( &mut *tx, &authed, @@ -723,6 +891,10 @@ async fn add_user_igroup( } } + // Apply instance-level role from group membership + let effective_role = compute_effective_instance_role(&email, &mut tx).await?; + apply_instance_role(&email, effective_role.as_deref(), &mut tx).await?; + tx.commit().await?; Ok(format!("Added {} to igroup {}", email, name)) } @@ -732,6 +904,7 @@ struct IGroup { name: String, summary: Option, emails: Option>, + instance_role: Option, } #[derive(Serialize)] @@ -739,6 +912,7 @@ struct IGroupWithWorkspaces { name: String, summary: Option, emails: Option>, + instance_role: Option, workspaces: Vec, } @@ -753,7 +927,7 @@ async fn list_igroups(Extension(db): Extension) -> JsonResult> { let groups = sqlx::query_as!( IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name" + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, instance_role" ) .fetch_all(&mut *tx) .await?; @@ -770,7 +944,7 @@ async fn list_igroups_with_workspaces( // Get all instance groups with their emails first let groups = sqlx::query_as!( IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary" + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary, instance_role" ) .fetch_all(&mut *tx) .await?; @@ -825,6 +999,7 @@ async fn list_igroups_with_workspaces( name: group.name, summary: group.summary, emails: group.emails, + instance_role: group.instance_role, workspaces, }); } @@ -833,16 +1008,54 @@ async fn list_igroups_with_workspaces( return Ok(Json(result)); } -async fn get_igroup(Path(name): Path, Extension(db): Extension) -> JsonResult { +async fn get_igroup( + Path(name): Path, + Extension(db): Extension, +) -> JsonResult { let group = sqlx::query_as!( IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name", + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name, instance_role", name ) .fetch_optional(&db) .await?; let group = not_found_if_none(group, "IGroup", &name)?; - return Ok(Json(group)); + + let workspace_mappings = sqlx::query!( + r#" + SELECT + ws.workspace_id, + w.name as workspace_name, + ws.auto_invite->'instance_groups_roles'->$1 as role + FROM workspace_settings ws + INNER JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false + WHERE ws.auto_invite->'instance_groups' ? $1 + ORDER BY ws.workspace_id + "#, + &name + ) + .fetch_all(&db) + .await?; + + let workspaces: Vec = workspace_mappings + .into_iter() + .map(|m| WorkspaceInfo { + workspace_id: m.workspace_id, + workspace_name: m.workspace_name, + role: m + .role + .and_then(|r| r.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "developer".to_string()), + }) + .collect(); + + return Ok(Json(IGroupWithWorkspaces { + name: group.name, + summary: group.summary, + emails: group.emails, + instance_role: group.instance_role, + workspaces, + })); } async fn remove_user_igroup( @@ -886,6 +1099,10 @@ async fn remove_user_igroup( remove_users_from_instance_group_workspaces(&email, &name, &mut tx).await?; } + // Recompute instance-level role after group removal + let effective_role = compute_effective_instance_role(&email, &mut tx).await?; + apply_instance_role(&email, effective_role.as_deref(), &mut tx).await?; + tx.commit().await?; Ok(format!("Removed {} from igroup {}", email, name)) } @@ -967,6 +1184,8 @@ struct ExportedIGroup { external_id: Option, #[serde(skip_serializing_if = "Option::is_none")] emails: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + instance_role: Option, } #[cfg(feature = "enterprise")] @@ -978,7 +1197,7 @@ async fn export_igroups( let mut tx = db.begin().await?; let igroups = sqlx::query_as!( ExportedIGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", ).fetch_all(&mut *tx).await?; audit_log( @@ -1022,13 +1241,15 @@ async fn overwrite_igroups( .await?; for igroup in igroups.iter() { + let validated_role = validate_instance_role(&igroup.instance_role)?; sqlx::query!( - "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id) VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id, instance_role) VALUES ($1, $2, $3, $4, $5, $6)", igroup.name, igroup.summary, igroup.id, igroup.scim_display_name, igroup.external_id, + validated_role, ) .execute(&mut *tx) .await?; @@ -1046,6 +1267,31 @@ async fn overwrite_igroups( } } + // Propagate instance roles for all groups that have one + for igroup in igroups.iter() { + if igroup.instance_role.is_some() { + propagate_instance_group_roles(&igroup.name, &mut tx).await?; + } + } + + // Demote orphaned users: those whose role was set by a group that no longer + // grants them any instance_role after the import + let orphaned_users = sqlx::query_scalar!( + "SELECT email FROM password + WHERE role_source = 'instance_group' AND (super_admin = true OR devops = true) + AND email NOT IN ( + SELECT eig.email FROM email_to_igroup eig + JOIN instance_group ig ON ig.name = eig.igroup + WHERE ig.instance_role IS NOT NULL + )" + ) + .fetch_all(&mut *tx) + .await?; + + for email in &orphaned_users { + apply_instance_role(email, None, &mut tx).await?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index b911739eaa..8f7bec1398 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -156,6 +156,7 @@ pub struct GlobalUserInfo { #[serde(skip_serializing_if = "Option::is_none")] operator_only: Option, first_time_user: bool, + role_source: String, } #[derive(Serialize, Debug)] @@ -395,7 +396,7 @@ async fn list_users_as_super_admin( GlobalUserInfo, "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), authors as (SELECT distinct email FROM usr WHERE usr.operator IS false) - SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user + SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source FROM password WHERE email IN (SELECT email FROM active_users) ORDER BY super_admin DESC, devops DESC @@ -408,7 +409,7 @@ async fn list_users_as_super_admin( } else { sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ + "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ $1 OFFSET $2", per_page as i32, offset as i32 @@ -624,7 +625,7 @@ async fn global_whoami( ) -> JsonResult { let user = sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user FROM password WHERE \ + "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE \ email = $1", email ) @@ -646,6 +647,7 @@ async fn global_whoami( username: None, operator_only: None, first_time_user: false, + role_source: "manual".to_string(), })) } else { Err(user.unwrap_err()) @@ -1281,7 +1283,7 @@ async fn update_user( let mut new_super_admin: Option = None; if let Some(sa) = eu.is_super_admin { sqlx::query_scalar!( - "UPDATE password SET super_admin = $1 WHERE email = $2", + "UPDATE password SET super_admin = $1, role_source = 'manual' WHERE email = $2", sa, &email_to_update ) @@ -1292,7 +1294,7 @@ async fn update_user( if let Some(dv) = eu.is_devops { sqlx::query_scalar!( - "UPDATE password SET devops = $1 WHERE email = $2", + "UPDATE password SET devops = $1, role_source = 'manual' WHERE email = $2", dv, &email_to_update ) @@ -1327,6 +1329,74 @@ async fn update_user( .await?; } + // If the result is "user" (no elevation), recompute from instance groups. + // Setting to "user" means "clear manual override, fall back to group role". + // Manual elevated roles (devops/superadmin) are never overridden by groups. + if eu.is_super_admin.is_some() || eu.is_devops.is_some() { + let current = sqlx::query!( + "SELECT super_admin, devops FROM password WHERE email = $1", + &email_to_update + ) + .fetch_optional(&mut *tx) + .await?; + + if let Some(c) = current { + if !c.super_admin && !c.devops { + // Compute effective role from all instance groups + let roles = sqlx::query_scalar!( + "SELECT ig.instance_role FROM email_to_igroup eig + JOIN instance_group ig ON ig.name = eig.igroup + WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + &email_to_update + ) + .fetch_all(&mut *tx) + .await?; + + let mut effective: Option<&str> = None; + for role in roles.iter().flatten() { + match role.as_str() { + "superadmin" => { + effective = Some("superadmin"); + break; + } + "devops" if effective.is_none() => { + effective = Some("devops"); + } + _ => {} + } + } + + if let Some(role) = effective { + let (sa, dv) = match role { + "superadmin" => (true, false), + _ => (false, true), + }; + sqlx::query!( + "UPDATE password SET super_admin = $1, devops = $2, role_source = 'instance_group' WHERE email = $3", + sa, dv, &email_to_update + ) + .execute(&mut *tx) + .await?; + + // Re-invalidate tokens with the group role + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session'", + &email_to_update + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "UPDATE token SET super_admin = $1 WHERE email = $2 AND label != 'session'", + sa, + &email_to_update + ) + .execute(&mut *tx) + .await?; + } + } + } + } + if let Some(n) = eu.name { sqlx::query_scalar!( "UPDATE password SET name = $1 WHERE email = $2", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7158f315ec..6a3532142c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -14164,7 +14164,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceGroup" + $ref: "#/components/schemas/InstanceGroupWithWorkspaces" /groups/create: post: @@ -14212,6 +14212,10 @@ paths: properties: new_summary: type: string + instance_role: + type: string + nullable: true + description: "Instance-level role for group members. 'superadmin', 'devops', 'user' or empty to clear." required: - new_summary responses: @@ -22629,6 +22633,12 @@ components: type: array items: type: string + instance_role: + type: string + nullable: true + enum: + - superadmin + - devops InstanceGroupWithWorkspaces: type: object @@ -22643,6 +22653,12 @@ components: type: array items: type: string + instance_role: + type: string + nullable: true + enum: + - superadmin + - devops workspaces: type: array items: @@ -22926,6 +22942,9 @@ components: type: boolean first_time_user: type: boolean + role_source: + type: string + enum: ["manual", "instance_group"] required: - email @@ -22933,6 +22952,7 @@ components: - super_admin - verified - first_time_user + - role_source Flow: allOf: @@ -23802,6 +23822,12 @@ components: type: string external_id: type: string + instance_role: + type: string + nullable: true + enum: + - superadmin + - devops required: - name diff --git a/frontend/src/lib/components/InstanceGroupEditor.svelte b/frontend/src/lib/components/InstanceGroupEditor.svelte index f3d8df33de..ffbd305462 100644 --- a/frontend/src/lib/components/InstanceGroupEditor.svelte +++ b/frontend/src/lib/components/InstanceGroupEditor.svelte @@ -1,7 +1,5 @@
@@ -56,7 +56,10 @@ on:click={async () => { await GroupService.updateInstanceGroup({ name, - requestBody: { new_summary: instance_group?.summary ?? '' } + requestBody: { + new_summary: instance_group?.summary ?? '', + instance_role: instance_group?.instance_role ?? 'user' + } }) dispatch('update') sendUserToast('New summary saved') @@ -64,6 +67,72 @@ >
+ + {#if $superadmin} +
+

Instance Role

+

Assign an instance-level role to all members of this group. Superadmin grants full admin + access, devops grants read-only admin visibility.

+ { + let role = e.detail + await GroupService.updateInstanceGroup({ + name, + requestBody: { + new_summary: instance_group?.summary ?? '', + instance_role: role + } + }) + if (instance_group) { + instance_group.instance_role = + role === 'user' ? undefined : (role as 'superadmin' | 'devops') + } + dispatch('update') + sendUserToast('Instance role updated') + }} + > + {#snippet children({ item })} + + + + {/snippet} + +
+ {/if} + + {#if instance_group.workspaces && instance_group.workspaces.length > 0} +
+

Workspace Membership

+ + {#snippet headerRow()} + + Workspace + Role + + {/snippet} + {#snippet body()} + + {#each instance_group?.workspaces ?? [] as ws (ws.workspace_id)} + + {ws.workspace_name ?? ws.workspace_id} + {ws.role} + + {/each} + + {/snippet} + +
+ {/if} +

Members ({#if members?.length != undefined}{members?.length ?? 0}{:else} {#snippet headerRow()} - + user - {/snippet} + {/snippet} {#snippet body()} - - {#each members as { member_email }} + + {#each members as { member_email } (member_email)} + {member_email} remove - {/each} + + {/each} - {/snippet} + {/snippet} {:else}
- {#each new Array(6) as _} + {#each new Array(6) as _, i (i)} {/each}
diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 37fca0037b..964414cafa 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -343,7 +343,7 @@ {#if filteredUsers && users} - {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only }, i (email)} + {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source }, i (email)} {/if} - { - if (email == $userStore?.email) { - sendUserToast('You cannot demote yourself', true) - listUsers(activeOnly) - return - } +
diff --git a/frontend/src/routes/(root)/(logged)/groups/+page.svelte b/frontend/src/routes/(root)/(logged)/groups/+page.svelte index 69b5b83c13..0668c4c307 100644 --- a/frontend/src/routes/(root)/(logged)/groups/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/groups/+page.svelte @@ -8,6 +8,7 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import Dropdown from '$lib/components/DropdownV2.svelte' import GroupEditor from '$lib/components/GroupEditor.svelte' + import InstanceGroupEditor from '$lib/components/InstanceGroupEditor.svelte' import GroupInfo from '$lib/components/GroupInfo.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -69,6 +70,8 @@ }) let editGroupName: string = $state('') + let instanceGroupDrawer: Drawer | undefined = $state() + let editInstanceGroupName: string = $state('') @@ -77,6 +80,15 @@ + + + + + + {#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.groups} {/if} + {#if node.workflow_as_code_status} +
+
Workflow timeline
+ +
+ {/if}
| null ): Promise { return abstractRun( () => @@ -310,7 +311,8 @@ tag, lock, script_hash: hash, - flow_path: flowPath + flow_path: flowPath, + modules: modules ?? undefined } }), callbacks diff --git a/frontend/src/lib/components/NoMainFuncBadge.svelte b/frontend/src/lib/components/NoMainFuncBadge.svelte index 3e2a2d28bb..c72e05583c 100644 --- a/frontend/src/lib/components/NoMainFuncBadge.svelte +++ b/frontend/src/lib/components/NoMainFuncBadge.svelte @@ -5,7 +5,7 @@ {#snippet text()} - The script has no main function exported + Library script (no exported main function) {/snippet} - No main + Library diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 7de0926f0a..eb8709751c 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -107,6 +107,22 @@ import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' import { buildForkEditUrl } from '$lib/utils/editInFork' import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' + import WacExportDrawer from './scripts/WacExportDrawer.svelte' + import Modal from './common/modal/Modal.svelte' + + const WAC_ALPHA_ACK_KEY = 'windmill_wac_alpha_ack' + let wacAlphaModalOpen = $state(false) + + function showWacAlphaModalIfNeeded() { + if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(WAC_ALPHA_ACK_KEY) !== 'true') { + wacAlphaModalOpen = true + } + } + + function acknowledgeWacAlpha() { + sessionStorage.setItem(WAC_ALPHA_ACK_KEY, 'true') + wacAlphaModalOpen = false + } let { script = $bindable(), @@ -182,6 +198,7 @@ let editor: Editor | undefined = $state(undefined) let scriptEditor: ScriptEditor | undefined = $state(undefined) let captureTable: CaptureTable | undefined = $state(undefined) + let wacExportDrawer: WacExportDrawer | undefined = $state(undefined) // Draft triggers confirmation modal let draftTriggersModalOpen = $state(false) @@ -362,6 +379,13 @@ } if (script.content == '') { + if (template === 'wac_python') { + script.modules = { 'helper.py': { content: 'def main(a: str) -> str:\n return f"hello {a}"\n', language: 'python3' } } + showWacAlphaModalIfNeeded() + } else if (template === 'wac_typescript') { + script.modules = { 'helper.ts': { content: 'export function main(a: string): string {\n return `hello ${a}`\n}\n', language: 'bun' } } + showWacAlphaModalIfNeeded() + } initContent(script.language, script.kind, template) } @@ -388,7 +412,7 @@ async function initContent( language: SupportedLanguage, kind: Script['kind'] | undefined, - template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' + template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' | 'wac_python' | 'wac_typescript' ) { scriptEditor?.disableCollaboration() const templateScript = await isTemplateScript() @@ -403,6 +427,7 @@ } async function handleEditScript(stay: boolean, deployMsg?: string): Promise { + scriptEditor?.flushModuleState() // Fetch latest version and fetch entire script after if needed let actual_parent_hash: string | undefined = undefined @@ -510,10 +535,10 @@ script.kind === 'preprocessor' ? 'preprocessor' : undefined ) if (script.kind === 'preprocessor') { - script.no_main_func = undefined + script.auto_kind = undefined script.has_preprocessor = undefined } else { - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } } catch (error) { @@ -554,12 +579,13 @@ timeout: script.timeout, concurrency_key: emptyString(script.concurrency_key) ? undefined : script.concurrency_key, visible_to_runner_only: script.visible_to_runner_only, - no_main_func: script.no_main_func, + auto_kind: script.auto_kind, has_preprocessor: script.has_preprocessor, deployment_message: deploymentMsg || undefined, on_behalf_of_email: script.on_behalf_of_email, preserve_on_behalf_of: preserveOnBehalfOf || undefined, - assets: script.assets + assets: script.assets, + modules: script.modules } }) @@ -592,7 +618,7 @@ if (!disableHistoryChange) { history.replaceState(history.state, '', `/scripts/edit/${script.path}`) } - if (stay || (script.no_main_func && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language))) { + if (stay || (script.auto_kind === 'lib' && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language))) { script.parent_hash = newHash sendUserToast('Deployed') } else { @@ -606,6 +632,7 @@ } async function saveDraft(forceSave = false): Promise { + scriptEditor?.flushModuleState() if (initialPath != '' && !savedScript) { return } @@ -643,10 +670,10 @@ script.kind === 'preprocessor' ? 'preprocessor' : undefined ) if (script.kind === 'preprocessor') { - script.no_main_func = undefined + script.auto_kind = undefined script.has_preprocessor = undefined } else { - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } } catch (error) { @@ -707,10 +734,11 @@ ? undefined : script.concurrency_key, visible_to_runner_only: script.visible_to_runner_only, - no_main_func: script.no_main_func, + auto_kind: script.auto_kind, has_preprocessor: script.has_preprocessor, on_behalf_of_email: script.on_behalf_of_email, - assets: script.assets + assets: script.assets, + modules: script.modules } }) } @@ -816,7 +844,7 @@ } ] : []), - ...(!script.draft_only && script.kind === 'script' && !script.no_main_func + ...(!script.draft_only && script.kind === 'script' && !script.auto_kind ? [ { label: 'Exit & See details', @@ -825,10 +853,34 @@ } } ] + : []), + ...(isWorkflowAsCode(script.content, script.language) + ? [ + { + label: 'Export as YAML/JSON', + onClick: () => { + wacExportDrawer?.open(script) + } + } + ] : []) ] : [] + if ( + dropdownItems.length === 0 && + isWorkflowAsCode(script.content, script.language) + ) { + dropdownItems = [ + { + label: 'Export as YAML/JSON', + onClick: () => { + wacExportDrawer?.open(script) + } + } + ] + } + return dropdownItems.length > 0 ? dropdownItems : undefined } @@ -1201,7 +1253,7 @@

{/if} -
+
Template + + + + + +
{#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true}
@@ -1948,9 +2040,23 @@ bind:hasPreprocessor bind:captureTable bind:assets={script.assets} + bind:modules={script.modules} enablePreprocessorSnippet />
{:else} Script Builder not available to operators {/if} + + + + +
+ diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index f310010c18..600dfde5af 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -2,7 +2,14 @@ import { BROWSER } from 'esm-env' import type { Schema, SupportedLanguage } from '$lib/common' - import { type CompletedJob, type Job, JobService, type Preview, type ScriptLang } from '$lib/gen' + import { + type CompletedJob, + type Job, + JobService, + type Preview, + type ScriptLang, + type ScriptModule + } from '$lib/gen' import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' import { copyToClipboard, @@ -25,8 +32,10 @@ import WindmillIcon from './icons/WindmillIcon.svelte' import * as Y from 'yjs' import { scriptLangToEditorLang } from '$lib/scripts' + import { langToExt } from '$lib/editorLangUtils' import { WebsocketProvider } from 'y-websocket' import Modal from './common/modal/Modal.svelte' + import Popover from './meltComponents/Popover.svelte' import DiffEditor from './DiffEditor.svelte' import { AlertTriangle, @@ -40,8 +49,11 @@ GitBranch, Play, PlayIcon, + Plus, Terminal, - WandSparkles + Pencil, + WandSparkles, + X } from 'lucide-svelte' import { DebugToolbar, @@ -100,7 +112,16 @@ path: string | undefined lang: Preview['language'] kind?: string | undefined - template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' + template?: + | 'pgsql' + | 'mysql' + | 'script' + | 'docker' + | 'powershell' + | 'bunnative' + | 'claudesandbox' + | 'wac_python' + | 'wac_typescript' tag: string | undefined initialArgs?: Record fixedOverflowWidgets?: boolean @@ -123,6 +144,7 @@ lastDeployedCode?: string | undefined disableAi?: boolean assets?: AssetWithAltAccessType[] + modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean } @@ -155,6 +177,7 @@ lastDeployedCode = undefined, disableAi = false, assets = $bindable(), + modules = $bindable(undefined), editorBarRight, enablePreprocessorSnippet = false }: Props = $props() @@ -163,6 +186,267 @@ let jsonView = $state(false) let schemaHeight = $state(0) + // Module tab state + let activeModuleTab: string | null = $state(null) + // editorCode is what the editor shows; code always holds the main script content + let editorCode: string = $state(code) + // Sync editorCode when code changes externally (template reset, copilot, etc.) + let lastSyncedCode = code + $effect.pre(() => { + if (activeModuleTab === null && code !== lastSyncedCode) { + editorCode = code + lastSyncedCode = code + } + }) + + function switchToModule(modulePath: string) { + if (activeModuleTab !== null && modules && activeModuleTab !== modulePath) { + // Switching from another module: save its content + modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + } + if (modules && modules[modulePath]) { + activeModuleTab = modulePath + editorCode = modules[modulePath].content + editor?.setCode(editorCode) + } + } + + function switchToMain() { + if (activeModuleTab !== null && modules) { + // Save current module content + modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + } + activeModuleTab = null + editorCode = code + lastSyncedCode = code + editor?.setCode(editorCode) + } + + let effectiveLang = $derived( + activeModuleTab && modules?.[activeModuleTab] + ? (modules[activeModuleTab].language as Preview['language']) + : lang + ) + + let isWacV2 = $derived.by(() => { + const mainCode = code + const isTsWac = + mainCode.includes('windmill-client') && + mainCode.includes('workflow') && + mainCode.includes('task') + const isPyWac = + (mainCode.includes('import wmill') || mainCode.includes('from wmill')) && + mainCode.includes('workflow') && + mainCode.includes('task') + return isTsWac || isPyWac + }) + let supportsModules = $derived((lang === 'bun' || lang === 'python3') && isWacV2) + let mainFileName = $derived('script.' + langToExt(scriptLangToEditorLang(lang))) + + let modulePathInput = $state('') + let showAddModulePopover = $state(false) + let modulePathInputEl: HTMLInputElement | undefined = $state(undefined) + let modulePathError = $state('') + + let renameModuleInput = $state('') + let renameModuleError = $state('') + let renameModuleInputEl: HTMLInputElement | undefined = $state(undefined) + + const ALL_MODULE_EXTENSIONS: Record = { + '.ts': 'bun', + '.py': 'python3', + '.go': 'go', + '.sh': 'bash', + '.ps1': 'powershell', + '.sql': 'postgresql', + '.gql': 'graphql', + '.php': 'php', + '.rs': 'rust', + '.yml': 'ansible', + '.cs': 'csharp', + '.nu': 'nu', + '.java': 'java', + '.rb': 'ruby' + } + + /** Map main script language to allowed module file extensions. */ + const LANG_MODULE_EXTENSIONS: Partial> = { + python3: ['.py'], + bun: ['.ts'], + deno: ['.ts'], + nativets: ['.ts'], + go: ['.go'], + bash: ['.sh'], + powershell: ['.ps1'], + postgresql: ['.sql'], + mysql: ['.sql'], + bigquery: ['.sql'], + snowflake: ['.sql'], + mssql: ['.sql'], + oracledb: ['.sql'], + duckdb: ['.sql'], + graphql: ['.gql'], + php: ['.php'], + rust: ['.rs'], + ansible: ['.yml'], + csharp: ['.cs'], + nu: ['.nu'], + java: ['.java'], + ruby: ['.rb'], + bunnative: ['.ts'] + } + + let allowedModuleExtensions = $derived( + lang + ? (LANG_MODULE_EXTENSIONS[lang] ?? Object.keys(ALL_MODULE_EXTENSIONS)) + : Object.keys(ALL_MODULE_EXTENSIONS) + ) + + function inferModuleLang(filePath: string): ScriptModule['language'] | undefined { + for (const [ext, moduleLang] of Object.entries(ALL_MODULE_EXTENSIONS)) { + if (filePath.endsWith(ext)) return moduleLang + } + return undefined + } + + function getModuleDefaultContent(filePath: string): string { + if (filePath.endsWith('.py')) { + return `def hello() -> str:\n return "world"\n` + } else if (filePath.endsWith('.ts')) { + return `export function hello(): string {\n return "world"\n}\n` + } else if (filePath.endsWith('.go')) { + return `package inner\n\nfunc Hello() string {\n\treturn "world"\n}\n` + } else if (filePath.endsWith('.sh')) { + return `#!/bin/bash\necho "world"\n` + } else if (filePath.endsWith('.ps1')) { + return `function Hello {\n return "world"\n}\n` + } else if (filePath.endsWith('.sql')) { + return `SELECT 'world' as result;\n` + } else if (filePath.endsWith('.gql')) { + return `query Hello {\n hello\n}\n` + } else if (filePath.endsWith('.php')) { + return ` String {\n "world".to_string()\n}\n` + } else if (filePath.endsWith('.yml')) { + return `---\n- name: Hello\n debug:\n msg: "world"\n` + } else if (filePath.endsWith('.cs')) { + return `public static string Hello() {\n return "world";\n}\n` + } else if (filePath.endsWith('.nu')) { + return `def hello [] {\n "world"\n}\n` + } else if (filePath.endsWith('.java')) { + return `public class Helper {\n public static String hello() {\n return "world";\n }\n}\n` + } else if (filePath.endsWith('.rb')) { + return `def hello\n "world"\nend\n` + } + return '' + } + + function validateModulePath(path: string): string { + if (!path.trim()) return '' + const moduleLang = inferModuleLang(path) + if (!moduleLang) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension: ${exts}` + } + const matchedExt = allowedModuleExtensions.find((ext) => path.endsWith(ext)) + if (!matchedExt) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension for this language: ${exts}` + } + if (modules?.[path.trim()]) { + return `Module ${path.trim()} already exists` + } + return '' + } + + function addModule() { + const modulePath = modulePathInput.trim() + if (!modulePath) return + const error = validateModulePath(modulePath) + if (error) { + modulePathError = error + return + } + if (!modules) { + modules = {} + } + modules[modulePath] = { + content: getModuleDefaultContent(modulePath), + language: inferModuleLang(modulePath)! + } + modulePathInput = '' + modulePathError = '' + showAddModulePopover = false + switchToModule(modulePath) + } + + function removeModule(modulePath: string) { + if (!modules) return + if (activeModuleTab === modulePath) { + switchToMain() + } + delete modules[modulePath] + modules = { ...modules } + } + + function validateRenameModulePath(newPath: string, oldPath: string): string { + if (!newPath.trim()) return '' + const moduleLang = inferModuleLang(newPath) + if (!moduleLang) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension: ${exts}` + } + const matchedExt = allowedModuleExtensions.find((ext) => newPath.endsWith(ext)) + if (!matchedExt) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension for this language: ${exts}` + } + if (newPath.trim() !== oldPath && modules?.[newPath.trim()]) { + return `Module ${newPath.trim()} already exists` + } + return '' + } + + function renameModule(oldPath: string) { + const newPath = renameModuleInput.trim() + if (!newPath || newPath === oldPath) { + return + } + const error = validateRenameModulePath(newPath, oldPath) + if (error) { + renameModuleError = error + return + } + if (!modules) return + const mod = modules[oldPath] + const newLang = inferModuleLang(newPath) + delete modules[oldPath] + modules[newPath] = { ...mod, language: newLang ?? mod.language } + modules = { ...modules } + if (activeModuleTab === oldPath) { + activeModuleTab = newPath + } + renameModuleInput = '' + renameModuleError = '' + } + + /** Save the active module tab's editor content back into the modules map (no UI side-effects). */ + function flushModuleContent() { + if (activeModuleTab !== null && modules) { + modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + } + } + + /** Flush module content and reset the editor back to the main script tab. */ + export function flushModuleState() { + if (activeModuleTab !== null && modules) { + flushModuleContent() + activeModuleTab = null + editorCode = code + } + } + $effect.pre(() => { if (schema == undefined) { schema = emptySchema() @@ -329,6 +613,8 @@ export async function runTest() { // Not defined if JobProgressBar not loaded jobProgressBar?.reset() + // Flush module edits back to modules map before running preview + flushModuleContent() //@ts-ignore let job = await jobLoader.runPreview( path, @@ -355,7 +641,9 @@ } console.error(error) } - } + }, + undefined, + modules ) logPanel?.setFocusToLogs() return job @@ -410,8 +698,7 @@ selectedTab = 'main' } else { hasPreprocessor = - (selectedTab === 'preprocessor' ? !result?.no_main_func : result?.has_preprocessor) ?? - false + (selectedTab === 'preprocessor' ? !result?.auto_kind : result?.has_preprocessor) ?? false if (!hasPreprocessor && selectedTab === 'preprocessor') { selectedTab = 'main' @@ -1316,140 +1603,333 @@ +{#snippet addModuleForm(close: () => void)} +
+ + { + modulePathError = validateModulePath(modulePathInput) + }} + onkeydown={(e) => { + if (e.key === 'Enter') addModule() + if (e.key === 'Escape') close() + }} + /> + {#if modulePathError} +

{modulePathError}

+ {/if} +

Supports subfolders, e.g. utils/math{allowedModuleExtensions[0] ?? '.ts'}

+
+ + +
+
+{/snippet} + +{#snippet renameModuleForm(oldPath: string, close: () => void)} +
+ + { + renameModuleError = validateRenameModulePath(renameModuleInput, oldPath) + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + renameModule(oldPath) + close() + } + if (e.key === 'Escape') close() + }} + /> + {#if renameModuleError} +

{renameModuleError}

+ {/if} +
+ + +
+
+{/snippet} + {#snippet editorContent()} -
-
- {#if assets?.length} - - {/if} - {#if isDebuggableScript && customUi?.editorBar?.debug != false} - - {/if} - {#if showDebugPanel && !showDebugConsole} - + {#each Object.keys(modules ?? {}) as modulePath} +
+ +
+ + {#snippet trigger()} + { + e.stopPropagation() + renameModuleInput = modulePath + renameModuleError = '' + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation() + renameModuleInput = modulePath + renameModuleError = '' + } + }} + > + + + {/snippet} + {#snippet content({ close })} + {@render renameModuleForm(modulePath, close)} + {/snippet} + + { + e.stopPropagation() + removeModule(modulePath) + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation() + removeModule(modulePath) + } + }} + > + + +
+
+ {/each} + - Console - - {/if} - {#if lang === 'ansible' && hasDelegateToGitRepo} - - {/if} - {#if testPanelSize === 0} - +
+ {/if} +
+
+ {#if assets?.length} + + {/if} + {#if isDebuggableScript && customUi?.editorBar?.debug != false} + + {/if} + {#if showDebugPanel && !showDebugConsole} + + {/if} + {#if lang === 'ansible' && hasDelegateToGitRepo} + + {/if} + {#if testPanelSize === 0} + btnClasses="bg-marine-400 hover:bg-marine-200 !text-primary-inverse hover:!text-primary-inverse hover:dark:!text-primary-inverse dark:bg-marine-50 dark:hover:bg-marine-50/70" + color="marine" + /> {/if} + {#if !aiChatManager.open && !disableAi} + {#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')} + + {/if} + {/if} +
+ + {#if debugConsoleVisible} + + + + {@render editorPane()} + + + (showDebugConsole = false)} + workspace={$workspaceStore} + jobId={debugSessionJobId ?? undefined} + /> + + + {:else} + +
+ {@render editorPane()} +
{/if}
- - {#if debugConsoleVisible} - - - - {@render editorPane()} - - - (showDebugConsole = false)} - workspace={$workspaceStore} - jobId={debugSessionJobId ?? undefined} - /> - - - {:else} - -
- {@render editorPane()} -
- {/if}
{/snippet} {#snippet editorPane()} - {#key lang} + {#key effectiveLang} { - inferSchema(e.detail) + if (activeModuleTab === null) { + code = editorCode + lastSyncedCode = code + inferSchema(e.detail) + } else { + flushModuleContent() + } // Refresh breakpoint positions when code changes (decorations track their lines) if (debugMode && breakpointDecorations.length > 0) { refreshBreakpointPositions() @@ -1458,20 +1938,24 @@ on:saveDraft on:toggleTestPanel={toggleTestPanel} cmdEnterAction={async () => { - await inferSchema(code) + if (activeModuleTab === null) { + await inferSchema(editorCode) + } runTest() }} formatAction={async () => { - await inferSchema(code) + if (activeModuleTab === null) { + await inferSchema(editorCode) + } try { - localStorage.setItem(path ?? 'last_save', code) + localStorage.setItem(path ?? 'last_save', activeModuleTab === null ? editorCode : code) } catch (e) { console.error('Could not save last_save to local storage', e) } dispatch('format') }} class="flex flex-1 h-full !overflow-visible" - scriptLang={lang} + scriptLang={effectiveLang} automaticLayout={true} {fixedOverflowWidgets} {args} diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index 66fbb5b6c5..d71a4bc015 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -3,17 +3,33 @@ import { displayDate, msToSec } from '$lib/utils' import { onDestroy } from 'svelte' import { getDbClockNow } from '$lib/forLater' - import { Loader2 } from 'lucide-svelte' + import { ChevronDown, ChevronRight, Loader2 } from 'lucide-svelte' import TimelineBar from './TimelineBar.svelte' - import type { WorkflowStatus } from '$lib/gen' + import LogViewer from './LogViewer.svelte' + import ObjectViewer from './propertyPicker/ObjectViewer.svelte' + import { CheckCircle2, XCircle } from 'lucide-svelte' + import { JobService, type Job, type WorkflowStatus } from '$lib/gen' + import { workspaceStore } from '$lib/stores' interface Props { - flow_status: Record; - flowDone?: boolean; + flow_status: Record + flowDone?: boolean + stepResults?: Record + result?: any + success?: boolean + autoExpandResult?: boolean } - let { flow_status, flowDone = false }: Props = $props(); + let { flow_status, flowDone = false, stepResults = {}, result = undefined, success = true, autoExpandResult = false }: Props = $props() + let resultExpanded = $state(false) + + // Auto-expand result row when job completes (only if requested) + $effect(() => { + if (autoExpandResult && flowDone && result !== undefined) { + resultExpanded = true + } + }) let now = $state(getDbClockNow().getTime()) @@ -25,35 +41,94 @@ onDestroy(() => { interval && clearInterval(interval) + pollInterval && clearInterval(pollInterval) }) - let min = $derived(Object.values(flow_status).reduce( - (a, b) => Math.min(a, b.scheduled_for ? new Date(b.scheduled_for).getTime() : Infinity), - Infinity - )) - let max = $derived(flowDone - ? Object.values(flow_status).reduce( - (a, b) => - Math.max(a, b.started_at ? new Date(b.started_at).getTime() + (b.duration_ms ?? 0) : 0), - 0 - ) - : undefined) + + let min = $derived( + Object.values(flow_status).reduce( + (a, b) => Math.min(a, b.scheduled_for ? new Date(b.scheduled_for).getTime() : Infinity), + Infinity + ) + ) + let max = $derived( + flowDone + ? Object.values(flow_status).reduce( + (a, b) => + Math.max( + a, + b.started_at ? new Date(b.started_at).getTime() + (b.duration_ms ?? 0) : 0 + ), + 0 + ) + : undefined + ) let total = $derived(flowDone && max ? max - min : Math.max(now - min, 2000)) + + // Collapsible state + let expandedRows: Record = $state({}) + let childJobs: Record = $state({}) + let loadingJobs: Record = $state({}) + + function isStep(key: string): boolean { + return key.startsWith('_step/') + } + + function stepKey(key: string): string { + return key.slice('_step/'.length) + } + + function toggleRow(id: string) { + expandedRows[id] = !expandedRows[id] + if (expandedRows[id] && !isStep(id) && !childJobs[id]) { + fetchChildJob(id) + } + } + + async function fetchChildJob(id: string) { + const ws = $workspaceStore + if (!ws) return + loadingJobs[id] = true + try { + const job = await JobService.getJob({ workspace: ws, id }) + childJobs[id] = job as Job & { result?: any } + } catch (e) { + console.error(`Failed to fetch job ${id}:`, e) + } finally { + loadingJobs[id] = false + } + } + + // Poll for updates on expanded in-progress jobs + let pollInterval = setInterval(() => { + for (const [id, v] of Object.entries(flow_status)) { + if (isStep(id)) continue + const isRunning = v.duration_ms == undefined && v.started_at != undefined + if (expandedRows[id] && isRunning) { + fetchChildJob(id) + } + } + }, 2000) {#if flow_status}
-
-
{min ? displayDate(new Date(min), true) : ''}
{#if max && min} - {/if}
{max ? displayDate(new Date(max), true) : ''}{#if !max && min}{#if now} +
+
+
+
{min ? displayDate(new Date(min), true) : ''}
+ {#if max && min} + + {/if} +
+ {max ? displayDate(new Date(max), true) : ''} + {#if !max && min} + {#if now} {msToSec(now - min, 3)}s - {/if}{/if}
+ {/if} + + {/if} +
+
@@ -61,26 +136,57 @@
Waiting for executor
-
Execution
- {#each Object.entries(flow_status) as [k, v] (k)} -
-
- {v.name ?? k} -
+ {#each Object.entries(flow_status).sort(([, a], [, b]) => { + const ta = new Date(a.started_at ?? a.scheduled_for ?? 0).getTime() + const tb = new Date(b.started_at ?? b.scheduled_for ?? 0).getTime() + return ta - tb + }) as [k, v] (k)} + {@const isInlineStep = isStep(k)} + {@const isRunning = v.duration_ms == undefined && v.started_at != undefined} + {@const isDone = v.duration_ms != undefined} + {@const isExpanded = expandedRows[k] ?? false} +
+
+ {/if} +
+ + + {#if isExpanded} +
+ {#if isInlineStep} + + {@const result = stepResults[stepKey(k)]} + {#if isDone && result !== undefined} +
+
Result
+
+ +
+
+ {:else} +
Step completed (no result)
+ {/if} + {:else if loadingJobs[k] && !childJobs[k]} +
+ + Loading... +
+ {:else if childJobs[k]} + {@const job = childJobs[k]} + + {#if job.logs || isRunning} +
+
Logs
+ +
+ {/if} + + + {#if isDone && job.result !== undefined} +
+
Result
+
+ +
+
+ {/if} + {:else} +
No data available
+ {/if} +
+ {/if}
{/each} + {#if flowDone && result !== undefined} +
+ + {#if resultExpanded} +
+
+ +
+
+ {/if} +
+ {/if}
{:else} diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index c98e326067..91a2bd3b3b 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -35,11 +35,14 @@ Share, Trash, History, - Globe2 + Globe2, + FileText } from 'lucide-svelte' import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte' + import WacExportDrawer from '$lib/components/scripts/WacExportDrawer.svelte' import { Drawer, DrawerContent } from '..' import NoMainFuncBadge from '$lib/components/NoMainFuncBadge.svelte' + import Popover from '$lib/components/Popover.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' import { scriptToHubUrl } from '$lib/hub' @@ -106,6 +109,7 @@ const dlt: 'delete' = 'delete' let versionsDrawerOpen: boolean = $state(false) + let wacExportDrawer: WacExportDrawer | undefined = $state(undefined) {#if menuOpen} @@ -115,7 +119,7 @@ Archived {/if} - {#if script.no_main_func && script.kind !== 'preprocessor'} + {#if script.auto_kind === 'lib' && script.kind !== 'preprocessor'} {/if} + {#if script.auto_kind === 'wac'} + + {#snippet text()} + Workflow-as-Code + {/snippet} + wac + + {/if} {#if script.kind !== 'script'} {script.kind === 'failure' ? 'Error handler' : capitalize(script.kind)} { + const fullScript = await ScriptService.getScriptByPath({ + workspace: $workspaceStore!, + path: script.path + }) + wacExportDrawer?.open(fullScript) + } + } + ] + : []), { displayName: 'Duplicate/Fork', icon: GitFork, @@ -412,3 +439,5 @@ {/if} + + diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index f2080fb281..73f8cfedba 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -7,11 +7,28 @@ import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import { importFlowStore } from '$lib/components/flows/flowStore.svelte' - import { Loader2, Plus } from 'lucide-svelte' + import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' + import Modal from '$lib/components/common/modal/Modal.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import Tabs from '$lib/components/common/tabs/Tabs.svelte' + import Tab from '$lib/components/common/tabs/Tab.svelte' + import { PythonIcon, TypeScriptIcon } from '$lib/components/common/languageIcons' + import { Code2, Loader2, Plus } from 'lucide-svelte' import YAML from 'yaml' + + const SKIP_FLOW_MODAL_KEY = 'windmill_skip_flow_modal' + let drawer: Drawer | undefined = $state(undefined) + let wacDrawer: Drawer | undefined = $state(undefined) let pendingRaw: string | undefined = $state(undefined) + let pendingWacRaw: string | undefined = $state(undefined) let importType: 'yaml' | 'json' = $state('yaml') + let wacImportType: 'yaml' | 'json' = $state('yaml') + let flowModalOpen = $state(false) + let wacHovered = $state(false) + let skipModal = $state( + typeof localStorage !== 'undefined' && localStorage.getItem(SKIP_FLOW_MODAL_KEY) === 'true' + ) async function importRaw() { $importFlowStore = @@ -19,58 +36,229 @@ await goto('/flows/add') drawer?.closeDrawer?.() } + + async function importWacRaw() { + const parsed = + wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '') + $importScriptStore = parsed + await goto(`${base}/scripts/add?import=true`) + wacDrawer?.closeDrawer?.() + } + + function handleFlowClick() { + if (skipModal) { + goto(`${base}/flows/add?nodraft=true`) + } else { + flowModalOpen = true + } + } + + function selectFlowEditor() { + flowModalOpen = false + goto(`${base}/flows/add?nodraft=true`) + } + + function selectWacPython() { + flowModalOpen = false + goto(`${base}/scripts/add?nodraft=true&wac=python`) + } + + function selectWacTypescript() { + flowModalOpen = false + goto(`${base}/scripts/add?nodraft=true&wac=typescript`) + } + + function toggleSkipModal() { + skipModal = !skipModal + localStorage.setItem(SKIP_FLOW_MODAL_KEY, String(skipModal)) + }
- -
- - - - drawer?.toggleDrawer?.()} + }, + { + label: 'Workflow-as-Code in TypeScript', + onClick: () => selectWacTypescript() + }, + { + label: 'Workflow-as-Code in Python', + onClick: () => selectWacPython() + }, + { + label: 'Import Workflow-as-Code', + onClick: () => { + wacDrawer?.toggleDrawer?.() + } + } + ]} > - {#await import('$lib/components/SimpleEditor.svelte')} - - {:then Module} - - {/await} + Flow + +
+ + + +
+
+ + + + + +
(wacHovered = true)} + onmouseleave={() => (wacHovered = false)} + > + +
+ Alpha +
+ + +
+
+ +
+
+

Workflow-as-Code

+

+ Write workflows as Python or TypeScript code as a regular Windmill script. +

+
+
+ + +
+ + +
+
+
+ +
+ + Always use the Flow editor (skip this modal) +
+
+
+ + + + drawer?.toggleDrawer?.()}> + + + + {#snippet content()} +
+ {#key importType} + {#await import('$lib/components/SimpleEditor.svelte')} + + {:then Module} + + {/await} + {/key} +
+ {/snippet} +
{#snippet actions()} {/snippet}
+ + + + wacDrawer?.toggleDrawer?.()}> + + + + {#snippet content()} +
+ {#key wacImportType} + {#await import('$lib/components/SimpleEditor.svelte')} + + {:then Module} + + {/await} + {/key} +
+ {/snippet} +
+ {#snippet actions()} + + {/snippet} +
+
diff --git a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte index abdb497e93..ae9d75daeb 100644 --- a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte @@ -50,7 +50,7 @@ dedicated_worker?: boolean visible_to_runner_only?: boolean on_behalf_of_email?: string - no_main_func?: boolean + auto_kind?: string has_preprocessor?: boolean } | undefined = $state(undefined) @@ -71,7 +71,7 @@ dedicated_worker?: boolean visible_to_runner_only?: boolean on_behalf_of_email?: string - no_main_func?: boolean + auto_kind?: string has_preprocessor?: boolean } | undefined = $state(undefined) @@ -82,7 +82,7 @@ script.schema = script.schema ?? emptySchema() try { const result = await inferArgs(script.language, script.content, script.schema) - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } catch (error) { sendUserToast(`Could not parse code, are you sure it is valid?`, true) diff --git a/frontend/src/lib/components/graph/model.ts b/frontend/src/lib/components/graph/model.ts index d6fcf4a33a..3618406371 100644 --- a/frontend/src/lib/components/graph/model.ts +++ b/frontend/src/lib/components/graph/model.ts @@ -1,4 +1,4 @@ -import type { FlowStatusModule, Job } from '$lib/gen' +import type { FlowStatusModule, Job, WorkflowStatus } from '$lib/gen' import type { StateStore } from '$lib/utils' import type { FlowState } from '../flows/flowState' @@ -67,6 +67,7 @@ export type GraphModuleState = { skipped?: boolean agent_actions?: FlowStatusModule['agent_actions'] script_hash?: string + workflow_as_code_status?: WorkflowStatus } export type NestedNodes = GraphItem[] diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index cae0144169..12b1b7c093 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -478,7 +478,7 @@ {/if}
diff --git a/frontend/src/lib/components/runs/JobRunsPreview.svelte b/frontend/src/lib/components/runs/JobRunsPreview.svelte index 274679d3bc..62722daac8 100644 --- a/frontend/src/lib/components/runs/JobRunsPreview.svelte +++ b/frontend/src/lib/components/runs/JobRunsPreview.svelte @@ -50,11 +50,15 @@ if (!x || typeof x !== 'object') return {} const result: Record = {} for (const [k, v] of Object.entries(x)) { - if (!k.startsWith('_')) result[k] = v as WorkflowStatus + if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus } return result } + function getStepResults(x: any): Record { + return x?._checkpoint?.completed_steps ?? {} + } + function handleFilterByConcurrencyKey(key: string) { dispatch('filterByConcurrencyKey', key) } @@ -156,6 +160,9 @@
{/if} diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index 63eb9d5a4c..51061f3fba 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -96,12 +96,18 @@ if (!x || typeof x !== 'object') return {} const result: Record = {} for (const [k, v] of Object.entries(x)) { - if (!k.startsWith('_')) result[k] = v as WorkflowStatus + if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus } return result } + function getStepResults(x: any): Record { + return x?._checkpoint?.completed_steps ?? {} + } + let forceJson = $state(false) + let isWac = $derived(!!previewJob?.workflow_as_code_status) + let wacDone = $derived(previewJob?.type == 'CompletedJob') @@ -141,16 +147,20 @@ {#snippet content()}
{#if selectedTab === 'logs'} + {#if isWac} +
+ +
+ {:else} - {#if previewJob?.workflow_as_code_status} - - - - {/if} + {/if} {/if} {#if selectedTab === 'history'}
diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index f224c3bd6a..ddc5a01b89 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -14,7 +14,7 @@ export interface ScriptBuilderProps { disableAi?: boolean fullyLoaded?: boolean initialPath?: string - template?: 'docker' | 'bunnative' | 'claudesandbox' | 'script' + template?: 'docker' | 'bunnative' | 'claudesandbox' | 'wac_python' | 'wac_typescript' | 'script' initialArgs?: Record lockedLanguage?: boolean showMeta?: boolean diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 9c847c9a3a..48480918e0 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/scripts/WacExportDrawer.svelte b/frontend/src/lib/components/scripts/WacExportDrawer.svelte new file mode 100644 index 0000000000..59f1dca1b0 --- /dev/null +++ b/frontend/src/lib/components/scripts/WacExportDrawer.svelte @@ -0,0 +1,113 @@ + + + + + + drawer?.toggleDrawer()}> +
+ + + + {#snippet content()} +
+
+
+ {#key rawType} + + {/key} +
+ {/snippet} +
+
+
+
diff --git a/frontend/src/lib/components/scripts/scriptStore.svelte.ts b/frontend/src/lib/components/scripts/scriptStore.svelte.ts new file mode 100644 index 0000000000..abc1fb0d63 --- /dev/null +++ b/frontend/src/lib/components/scripts/scriptStore.svelte.ts @@ -0,0 +1,4 @@ +import type { NewScript } from '$lib/gen' +import { writable } from 'svelte/store' + +export const importScriptStore = writable(undefined) diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index 9861e9b016..b1b822419b 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -236,7 +236,7 @@ export async function inferArgs( schema: Schema, mainOverride?: string ): Promise<{ - no_main_func: boolean | null + auto_kind: string | null has_preprocessor: boolean | null } | null> { const lastRun = get(loadSchemaLastRun) @@ -398,7 +398,7 @@ export async function inferArgs( await tick() return { - no_main_func: inferedSchema.no_main_func, + auto_kind: inferedSchema.auto_kind, has_preprocessor: inferedSchema.has_preprocessor } } diff --git a/frontend/src/lib/script_helpers.ts b/frontend/src/lib/script_helpers.ts index 5744971f32..d05d78f6ef 100644 --- a/frontend/src/lib/script_helpers.ts +++ b/frontend/src/lib/script_helpers.ts @@ -3,6 +3,8 @@ import { type Script } from './gen' import type { SupportedLanguage } from './common' import CLAUDE_SANDBOX_INIT_CODE from './templates/claude_sandbox.ts.template?raw' +import WAC_PYTHON_INIT_CODE from './templates/wac_python.py.template?raw' +import WAC_TYPESCRIPT_INIT_CODE from './templates/wac_typescript.ts.template?raw' const PYTHON_FAILURE_MODULE_CODE = `import os @@ -1378,6 +1380,12 @@ export const INITIAL_CODE = { }, claudesandbox: { script: CLAUDE_SANDBOX_INIT_CODE + }, + wac_python: { + script: WAC_PYTHON_INIT_CODE + }, + wac_typescript: { + script: WAC_TYPESCRIPT_INIT_CODE } // for related places search: ADD_NEW_LANG } @@ -1406,6 +1414,8 @@ export function initialCode( | 'powershell' | 'bunnative' | 'claudesandbox' + | 'wac_python' + | 'wac_typescript' | undefined, templateScript?: boolean ): string { @@ -1436,6 +1446,10 @@ export function initialCode( } else { return INITIAL_CODE.deno.script } + } else if (subkind === 'wac_python') { + return INITIAL_CODE.wac_python.script + } else if (subkind === 'wac_typescript') { + return INITIAL_CODE.wac_typescript.script } else if (language === 'python3') { if (kind === 'trigger') { return INITIAL_CODE.python3.trigger @@ -1538,6 +1552,8 @@ export function getResetCode( | 'powershell' | 'bunnative' | 'claudesandbox' + | 'wac_python' + | 'wac_typescript' | undefined ) { if (language === 'deno') { diff --git a/frontend/src/lib/templates/wac_python.py.template b/frontend/src/lib/templates/wac_python.py.template new file mode 100644 index 0000000000..42f166ecc0 --- /dev/null +++ b/frontend/src/lib/templates/wac_python.py.template @@ -0,0 +1,42 @@ +from wmill import task, task_script, step, sleep, wait_for_approval, get_resume_urls, workflow + +# IMPORTANT: All computation must happen inside @task(), task_script(), or step(). +# Code outside these wrappers is NOT checkpointed and WILL be re-executed +# on every resume or retry. Never put API calls, database writes, or +# non-deterministic logic (e.g. datetime.now()) in the top-level workflow body. + +# task_script() references a module file (see the helper.py tab) +helper = task_script("./helper.py") + + +# @task() wraps a function as a workflow step that runs as a separate job. +# The result is checkpointed — on retry, completed tasks are skipped. +@task() +async def process(x: str) -> str: + return f"processed: {x}" + + +@workflow +async def main(x: str): + a = await process(x) + + # task_script() calls a module file as a separate job (also checkpointed) + b = await helper(a=a) + + # step() runs inline code and checkpoints the result (no child job). + # Use it for lightweight operations you don't want as a separate script. + urls = await step("get_urls", lambda: get_resume_urls()) + + # sleep() suspends the workflow server-side without holding a worker + await sleep(1) + + # wait_for_approval() suspends until an external event resumes it. + # Like sleep(), it does not hold a worker. Approve/reject URLs are + # available in the timeline step's details in the UI. + approval = await wait_for_approval(timeout=3600) + + return { + "processed": a, + "helper_result": b, + "approval": approval, + } diff --git a/frontend/src/lib/templates/wac_typescript.ts.template b/frontend/src/lib/templates/wac_typescript.ts.template new file mode 100644 index 0000000000..0d22d6d402 --- /dev/null +++ b/frontend/src/lib/templates/wac_typescript.ts.template @@ -0,0 +1,48 @@ +import { + task, + taskScript, + step, + sleep, + waitForApproval, + getResumeUrls, + workflow, +} from "windmill-client"; + +// IMPORTANT: All computation must happen inside task(), taskScript(), or step(). +// Code outside these wrappers is NOT checkpointed and WILL be re-executed +// on every resume or retry. Never put API calls, database writes, or +// non-deterministic logic (e.g. Date.now()) in the top-level workflow body. + +// taskScript() references a module file (see the helper.ts tab) +const helper = taskScript("./helper.ts"); + +// task() wraps a function as a workflow step that runs as a separate job. +// The result is checkpointed — on retry, completed tasks are skipped. +const process = task(async (x: string): Promise => { + return `processed: ${x}`; +}); + +export const main = workflow(async (x: string) => { + const a = await process(x); + + // taskScript() calls a module file as a separate job (also checkpointed) + const b = await helper({ a }); + + // step() runs inline code and checkpoints the result (no child job). + // Use it for lightweight operations you don't want as a separate script. + const urls = await step("get_urls", () => getResumeUrls()); + + // sleep() suspends the workflow server-side without holding a worker + await sleep(1); + + // waitForApproval() suspends until an external event resumes it. + // Like sleep(), it does not hold a worker. Approve/reject URLs are + // available in the timeline step's details in the UI. + const approval = await waitForApproval({ timeout: 3600 }); + + return { + processed: a, + helper_result: b, + approval, + }; +}); diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index ac73b934fd..5bf8f21ac5 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -284,11 +284,15 @@ if (!x || typeof x !== 'object') return {} const result: Record = {} for (const [k, v] of Object.entries(x)) { - if (!k.startsWith('_')) result[k] = v as WorkflowStatus + if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus } return result } + function getStepResults(x: any): Record { + return x?._checkpoint?.completed_steps ?? {} + } + function forkPreview() { if (isFlowPreview(job?.job_kind)) { const state = { @@ -790,6 +794,9 @@
diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte index afc3f9e270..8f43dde796 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte @@ -14,6 +14,8 @@ import { get } from 'svelte/store' import { untrack } from 'svelte' import ScriptEditorSkeleton from '$lib/components/ScriptEditorSkeleton.svelte' + import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' + import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow' type Script = NewScript & { draft_triggers?: Trigger[] @@ -29,6 +31,8 @@ const showMeta = /true|1/i.test(page.url.searchParams.get('show_meta') ?? '0') const urlArgs = page.url.searchParams.get('initial_args') const collabLang = page.url.searchParams.get('lang') as ScriptLang | null + const wacParam = page.url.searchParams.get('wac') + const importParam = page.url.searchParams.get('import') let initialArgs = urlArgs ? decodeState(urlArgs) : (get(initialArgsStore) ?? {}) if (get(initialArgsStore)) $initialArgsStore = undefined @@ -59,7 +63,7 @@ schema: schema, is_template: false, extra_perms: {}, - language: collabLang ?? ($defaultScripts?.order?.filter( + language: (wacParam === 'python' ? 'python3' : wacParam === 'typescript' ? 'bun' : null) ?? collabLang ?? ($defaultScripts?.order?.filter( (x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x) )?.[0] ?? 'bun') as ScriptLang, kind: 'script' @@ -122,6 +126,27 @@ loadHub() + let importedWacTemplate: 'wac_python' | 'wac_typescript' | undefined = undefined + if (importParam && $importScriptStore) { + const imported = $importScriptStore + $importScriptStore = undefined + const isWac = isWorkflowAsCode(imported.content ?? '', imported.language ?? '') + script = { + ...defaultScript(), + ...imported, + path: path ?? '', + hash: '', + extra_perms: {} + } + if (isWac) { + importedWacTemplate = + imported.language === 'python3' ? 'wac_python' : 'wac_typescript' + sendUserToast('WAC script loaded from YAML/JSON') + } else { + sendUserToast('Script loaded from YAML/JSON') + } + } + $effect(() => { if ($workspaceStore) { untrack(() => loadTemplate()) @@ -134,6 +159,7 @@ {initialArgs} bind:this={scriptBuilder} lockedLanguage={templatePath != null || hubPath != null} + template={importedWacTemplate ?? (wacParam === 'python' ? 'wac_python' : wacParam === 'typescript' ? 'wac_typescript' : 'script')} onDeploy={(e) => { goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index 4fa95cf647..4ba269b74a 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -62,11 +62,14 @@ Trash, Play, ClipboardCopy, - LayoutDashboard + LayoutDashboard, + ChevronDown, + ChevronRight } from 'lucide-svelte' import { SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB } from '$lib/consts' import { scriptToHubUrl } from '$lib/hub' import SharedBadge from '$lib/components/SharedBadge.svelte' + import Popover from '$lib/components/Popover.svelte' import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte' import { createAppFromScript } from '$lib/components/details/createAppFromScript' import { importStore } from '$lib/components/apps/store' @@ -95,6 +98,8 @@ let can_write = $state(false) let isHubScript = $state(false) let deploymentInProgress = $state(false) + let expandedModuleLocks: Record = $state({}) + let expandedModuleCode: Record = $state({}) let deploymentJobId: string | undefined = $state(undefined) let intervalId: number let shareModal: ShareModal | undefined = $state() @@ -208,7 +213,7 @@ kind: 'script', starred: false, schema: hubScript.schema as Script['schema'], - no_main_func: false, + auto_kind: undefined, has_preprocessor: false } can_write = false @@ -352,7 +357,12 @@ }) } - if (script && !$userStore?.operator && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')) { + if ( + script && + !$userStore?.operator && + !isCloudHosted() && + !isRuleActive('DisableWorkspaceForking') + ) { buttons.push({ label: 'Edit in fork', buttonProps: { @@ -680,6 +690,14 @@ {#if $workspaceStore && script} {/if} + {#if script?.auto_kind === 'wac'} + + {#snippet text()} + Workflow-as-Code + {/snippet} + wac + + {/if} {#if script?.codebase} bundle
+ {#if script?.modules} + {#each Object.entries(script.modules) as [modulePath, mod]} +
+ + {#if expandedModuleCode[modulePath]} +
+ +
+ {/if} +
+ {/each} + {/if}
@@ -942,6 +987,45 @@ There is no lock file for this script

{/if} + {#if script?.modules} + {@const moduleEntries = Object.entries(script.modules).filter( + ([_, m]) => m.lock + )} + {#each moduleEntries as [modulePath, mod]} +
+ + {#if expandedModuleLocks[modulePath]} +
+
+ {/if} +
+ {/each} + {/if}
diff --git a/python-client/wmill/uv.lock b/python-client/wmill/uv.lock new file mode 100644 index 0000000000..6c1c3ab219 --- /dev/null +++ b/python-client/wmill/uv.lock @@ -0,0 +1,139 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[manifest] + +[manifest.dependency-groups] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.2" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index c8c30de4f1..cdd3342771 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -11,7 +11,7 @@ import time import warnings import json from json import JSONDecodeError -from typing import Dict, Any, Union, Literal, Optional +from typing import Callable, Dict, Any, Union, Literal, Optional import re import httpx @@ -2431,6 +2431,7 @@ class WorkflowCtx: else: return self._never_resolve() + print(f"\n--- WAC: {key} ---") info = {"name": name or key, "script": script or key, "args": kwargs, "key": key, "dispatch_type": dispatch_type} if _task_options: for opt_key in ("timeout", "tag", "cache_ttl", "priority", "concurrent_limit", "concurrency_key", "concurrency_time_window_s"): @@ -2472,6 +2473,7 @@ class WorkflowCtx: if self._executing_key is not None: await _asyncio.Future() + print(f"\n--- WAC: wait_for_approval({key}) ---") raise _StepSuspend({ "mode": "approval", "key": key, @@ -2489,6 +2491,7 @@ class WorkflowCtx: if self._executing_key is not None: await _asyncio.Future() + print(f"\n--- WAC: sleep({key}, {seconds}s) ---") raise _StepSuspend({ "mode": "sleep", "key": key, @@ -2497,6 +2500,10 @@ class WorkflowCtx: }) async def _run_inline_step(self, name: str, fn): + import json as _json_mod + import time as _time_mod + from datetime import datetime as _dt, timezone as _tz + key = self._alloc_key(name or "step") if key in self._completed: @@ -2513,15 +2520,22 @@ class WorkflowCtx: if self._executing_key is not None: await _asyncio.Future() + print(f"\n--- WAC: {key} ---") + started_at = _dt.now(_tz.utc).isoformat() + print(f"WM_WAC_STEP: {_json_mod.dumps({'key': key, 'started_at': started_at})}") + t0 = _time_mod.monotonic() result = fn() if _asyncio.iscoroutine(result): result = await result + duration_ms = int((_time_mod.monotonic() - t0) * 1000) raise _StepSuspend({ "mode": "inline_checkpoint", "steps": [], "key": key, "result": result, + "started_at": started_at, + "duration_ms": duration_ms, }) @@ -2568,7 +2582,7 @@ def task( # Remove None values _task_opts = {k: v for k, v in _task_opts.items() if v is not None} or None - def decorator(func): + def decorator(func) -> Callable[..., Any]: task_path = path task_name = func.__name__ @@ -2585,7 +2599,6 @@ def task( merged[f"arg{i}"] = arg return merged - @functools.wraps(func) def wrapper(*args, **kwargs): # WAC v2: inside a @workflow context ctx = _workflow_ctx.get(None) @@ -2815,11 +2828,16 @@ async def _run_workflow_async(func, checkpoint: dict, input_args: dict): if mode == "step_complete": return {"type": "complete", "result": info.get("result")} if mode == "inline_checkpoint": - return { + out = { "type": "inline_checkpoint", "key": info["key"], "result": info.get("result"), } + if "started_at" in info: + out["started_at"] = info["started_at"] + if "duration_ms" in info: + out["duration_ms"] = info["duration_ms"] + return out if mode == "approval": return { "type": "approval", diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 8578c98bb6..49087a8b60 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -1558,6 +1558,8 @@ export class WorkflowCtx { this._suspended = true; const steps = [...this.pending]; this.pending = []; + const names = steps.map(s => s.name).join(", "); + console.log(`\n--- WAC: ${names} ---`); throw new StepSuspend({ mode: steps.length > 1 ? "parallel" : "sequential", steps, @@ -1589,6 +1591,7 @@ export class WorkflowCtx { } // Throw immediately — approval is always a blocking step + console.log(`\n--- WAC: approval(${key}) ---`); throw new StepSuspend({ mode: "approval", key, @@ -1609,6 +1612,7 @@ export class WorkflowCtx { return { then: () => new Promise(() => {}) }; } + console.log(`\n--- WAC: sleep(${key}, ${seconds}s) ---`); throw new StepSuspend({ mode: "sleep", key, @@ -1636,8 +1640,13 @@ export class WorkflowCtx { return new Promise(() => {}); } + console.log(`\n--- WAC: ${key} ---`); + const startedAt = new Date().toISOString(); + console.log(`WM_WAC_STEP: ${JSON.stringify({ key, started_at: startedAt })}`); + const t0 = Date.now(); const result = await fn(); - throw new StepSuspend({ mode: "inline_checkpoint", steps: [], key, result }); + const durationMs = Date.now() - t0; + throw new StepSuspend({ mode: "inline_checkpoint", steps: [], key, result, started_at: startedAt, duration_ms: durationMs }); } } From 6165e01e8afb89a1b86049e40faece12a595040b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 01:27:31 +0000 Subject: [PATCH 73/76] fix migration clash --- ..._role.down.sql => 20260316000005_instance_group_role.down.sql} | 0 ...roup_role.up.sql => 20260316000005_instance_group_role.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename backend/migrations/{20260316000000_instance_group_role.down.sql => 20260316000005_instance_group_role.down.sql} (100%) rename backend/migrations/{20260316000000_instance_group_role.up.sql => 20260316000005_instance_group_role.up.sql} (100%) diff --git a/backend/migrations/20260316000000_instance_group_role.down.sql b/backend/migrations/20260316000005_instance_group_role.down.sql similarity index 100% rename from backend/migrations/20260316000000_instance_group_role.down.sql rename to backend/migrations/20260316000005_instance_group_role.down.sql diff --git a/backend/migrations/20260316000000_instance_group_role.up.sql b/backend/migrations/20260316000005_instance_group_role.up.sql similarity index 100% rename from backend/migrations/20260316000000_instance_group_role.up.sql rename to backend/migrations/20260316000005_instance_group_role.up.sql From ced6f622072efe3ac1ae0f24c18cf157180230c7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 01:36:03 +0000 Subject: [PATCH 74/76] chore: trigger CLI tests on migration changes and release tags Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cli-tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 20a24d2091..5478e8985e 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -3,13 +3,17 @@ name: CLI Tests on: push: branches: [main] + tags: + - "v*" paths: - "cli/**" + - "backend/migrations/**" - ".github/workflows/cli-tests.yml" pull_request: branches: [main] paths: - "cli/**" + - "backend/migrations/**" - ".github/workflows/cli-tests.yml" env: From 4e7be0d27a392135f111cccda5800e5ba1563923 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 01:39:24 +0000 Subject: [PATCH 75/76] chore: run windows backend tests on release tags Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/backend-test-windows.yml | 2 ++ .github/workflows/cli-tests.yml | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index ca9ce2aaac..864693ebda 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -5,6 +5,8 @@ on: push: branches: - "ci-windows-tests" + tags: + - "v*" env: CARGO_INCREMENTAL: 0 diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 5478e8985e..9c87a249a3 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -3,8 +3,6 @@ name: CLI Tests on: push: branches: [main] - tags: - - "v*" paths: - "cli/**" - "backend/migrations/**" From 8cd2d06f0175c3a412883fa2e5ed4b9a608fdfa0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 01:50:16 +0000 Subject: [PATCH 76/76] nit fix app --- frontend/package-lock.json | 4 ++-- frontend/src/lib/components/apps/editor/appUtilsS3.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 01eea55d67..9f9a907e19 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -162,8 +162,8 @@ } }, "../backend/parsers/windmill-parser-wasm/pkg-ts": { - "name": "windmill-parser-wasm-ts", - "version": "1.657.2" + "name": "windmill-parser-wasm", + "version": "1.654.0" }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", diff --git a/frontend/src/lib/components/apps/editor/appUtilsS3.ts b/frontend/src/lib/components/apps/editor/appUtilsS3.ts index 5e35f1a4bd..f9811edd76 100644 --- a/frontend/src/lib/components/apps/editor/appUtilsS3.ts +++ b/frontend/src/lib/components/apps/editor/appUtilsS3.ts @@ -153,8 +153,8 @@ export function computeS3FileViewerPolicy(config: RichConfigurations) { } else if ( config.source.type === 'static' && typeof config.source.value === 'string' && - ((config.sourceKind.type === 'static' && - config.sourceKind.value === 's3 (workspace storage)') || + ((config.sourceKind?.type === 'static' && + config.sourceKind?.value === 's3 (workspace storage)') || config.source.value.startsWith('s3://')) ) { return {