From 54f5a19377e9df712e18f85f896e21b1776981ed Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 13:23:43 +0000 Subject: [PATCH 001/153] fix: prevent SQL injection in job query parameters (#8494) Replace unsafe string interpolation (format!("'{}'", t)) with sql_builder::quote() in SQL query construction. The tags parameter in count_completed_jobs_detail was directly interpolated without escaping, allowing authenticated users to inject arbitrary SQL via the query string. Also hardens LIKE clauses, JSON operators, and JOIN conditions across query.rs and variables.rs that used manual .replace("'", "''") instead of the crate's quote() function, and converts format-interpolated bind values to parameterized queries where possible. Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-api-jobs/src/query.rs | 52 ++++++++++++------------- backend/windmill-api/src/jobs.rs | 5 +-- backend/windmill-store/src/variables.rs | 15 ++----- 3 files changed, 28 insertions(+), 44 deletions(-) diff --git a/backend/windmill-api-jobs/src/query.rs b/backend/windmill-api-jobs/src/query.rs index d8128d90e7..b1b3fb4f0a 100644 --- a/backend/windmill-api-jobs/src/query.rs +++ b/backend/windmill-api-jobs/src/query.rs @@ -50,11 +50,11 @@ pub fn filter_list_queue_query( .values .iter() .map(|v| { - let p = v.replace("*", "%").replace("'", "''"); + let p = v.replace("*", "%"); if w.negated { - format!("v2_job_queue.worker NOT LIKE '{p}'") + format!("v2_job_queue.worker NOT LIKE {}", quote(&p)) } else { - format!("v2_job_queue.worker LIKE '{p}'") + format!("v2_job_queue.worker LIKE {}", quote(&p)) } }) .collect(); @@ -77,11 +77,11 @@ pub fn filter_list_queue_query( .values .iter() .map(|v| { - let e = v.replace("'", "''"); + let p = format!("{}%", v); if ps.negated { - format!("runnable_path NOT LIKE '{e}%'") + format!("runnable_path NOT LIKE {}", quote(&p)) } else { - format!("runnable_path LIKE '{e}%'") + format!("runnable_path LIKE {}", quote(&p)) } }) .collect(); @@ -123,11 +123,11 @@ pub fn filter_list_queue_query( .values .iter() .map(|v| { - let p = v.replace("*", "%").replace("'", "''"); + let p = v.replace("*", "%"); if t.negated { - format!("v2_job.tag NOT LIKE '{p}'") + format!("v2_job.tag NOT LIKE {}", quote(&p)) } else { - format!("v2_job.tag LIKE '{p}'") + format!("v2_job.tag LIKE {}", quote(&p)) } }) .collect(); @@ -287,14 +287,14 @@ pub fn filter_list_completed_query( .values .iter() .map(|v| { - let p = v.replace("*", "%").replace("'", "''"); + let p = v.replace("*", "%"); if label.negated { format!( - "NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')" + "NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE {})", quote(&p) ) } else { format!( - "EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')" + "EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE {})", quote(&p) ) } }) @@ -308,14 +308,14 @@ pub fn filter_list_completed_query( let clauses: Vec<_> = label .values .iter() - .map(|v| format!("NOT (result->'wm_labels' ? '{}')", v.replace("'", "''"))) + .map(|v| format!("NOT (result->'wm_labels' ? {})", quote(v))) .collect(); sqlb.and_where(format!("({})", clauses.join(" AND "))); } else { let clauses: Vec<_> = label .values .iter() - .map(|v| format!("result->'wm_labels' ? '{}'", v.replace("'", "''"))) + .map(|v| format!("result->'wm_labels' ? {}", quote(v))) .collect(); sqlb.and_where("result ? 'wm_labels'"); sqlb.and_where(format!("({})", clauses.join(" OR "))); @@ -329,11 +329,11 @@ pub fn filter_list_completed_query( .values .iter() .map(|v| { - let p = v.replace("*", "%").replace("'", "''"); + let p = v.replace("*", "%"); if worker.negated { - format!("v2_job_completed.worker NOT LIKE '{p}'") + format!("v2_job_completed.worker NOT LIKE {}", quote(&p)) } else { - format!("v2_job_completed.worker LIKE '{p}'") + format!("v2_job_completed.worker LIKE {}", quote(&p)) } }) .collect(); @@ -366,11 +366,11 @@ pub fn filter_list_completed_query( .values .iter() .map(|v| { - let e = v.replace("'", "''"); + let p = format!("{}%", v); if ps.negated { - format!("runnable_path NOT LIKE '{e}%'") + format!("runnable_path NOT LIKE {}", quote(&p)) } else { - format!("runnable_path LIKE '{e}%'") + format!("runnable_path LIKE {}", quote(&p)) } }) .collect(); @@ -400,11 +400,11 @@ pub fn filter_list_completed_query( .values .iter() .map(|v| { - let p = v.replace("*", "%").replace("'", "''"); + let p = v.replace("*", "%"); if t.negated { - format!("v2_job.tag NOT LIKE '{p}'") + format!("v2_job.tag NOT LIKE {}", quote(&p)) } else { - format!("v2_job.tag LIKE '{p}'") + format!("v2_job.tag LIKE {}", quote(&p)) } }) .collect(); @@ -449,11 +449,7 @@ pub fn filter_list_completed_query( } if let Some(dt) = &lq.created_or_started_after { let ts = dt.to_rfc3339(); - sqlb.and_where(format!( - "(created_at >= '{}' OR started_at >= '{}')", - ts.replace("'", "''"), - ts.replace("'", "''") - )); + sqlb.and_where("(created_at >= ? OR started_at >= ?)".bind(&ts).bind(&ts)); } if let Some(dt) = &lq.created_before { diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 7f9a3f36db..a99b2737a8 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -2027,10 +2027,7 @@ async fn count_completed_jobs_detail( if let Some(tags) = query.tags { sqlb.and_where_in( "v2_job.tag", - &tags - .split(",") - .map(|t| format!("'{}'", t)) - .collect::>(), + &tags.split(",").map(|t| quote(t)).collect::>(), ); } diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 544f7d6c0a..7d2c8cbfe8 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -133,21 +133,12 @@ async fn list_variables( ]) .left() .join("account") - .on(&format!( - "variable.account = account.id AND account.workspace_id = '{}'", - w_id - )) + .on("variable.account = account.id AND account.workspace_id = ?".bind(&w_id)) .left() .join("resource") - .on(&format!( - "resource.path = variable.path AND resource.workspace_id = '{}'", - w_id - )) + .on("resource.path = variable.path AND resource.workspace_id = ?".bind(&w_id)) .and_where("variable.workspace_id = ?".bind(&w_id)) - .and_where(&format!( - "variable.path NOT LIKE 'u/' || '{}' || '/secret_arg/%'", - authed.username - )) + .and_where("variable.path NOT LIKE 'u/' || ? || '/secret_arg/%'".bind(&authed.username)) .order_by("path", false) .limit(per_page) .offset(offset) From 47c0c363f4fc1d9af7efd07ea172e32989ce50d2 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 24 Mar 2026 14:25:07 +0100 Subject: [PATCH 002/153] fix: clean up stale dependency map entries for renamed scripts (#8492) * fix: clean up stale dependency map entries for renamed scripts When a script is renamed, trigger_dependents_to_recompute_dependencies() could find the archived script at the old path and create a dependency job for it. This job would process the old code and recreate stale dependency_map entries, causing incorrect deployment warnings. Add `AND archived = false` to the script lookup query so that renamed (archived) scripts at old paths trigger clear_map_for_item() cleanup instead of spawning dependency jobs for obsolete code. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: also filter archived flows in trigger_dependents Apply the same archived check to the flow lookup query. The flow table has an archived column, so when a flow is renamed/archived its flow_version rows would still be found. Join against the flow table and filter archived = false to trigger cleanup instead. Co-Authored-By: Claude Opus 4.6 (1M context) * revert: remove unnecessary flow archived check Flow renames delete the old flow row and INSERT a new one at the new path (for FK constraints on flow_version). There is no archived flow row left behind, so the original query is already correct for flows. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...17869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...226fd65328475731526e0b20ea6eeafeb937eb01cdc2cdfcb859.json} | 4 ++-- backend/windmill-dep-map/src/trigger_dependents.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename backend/.sqlx/{query-d5661c7557cf3a8dee7cf799cd364d21d38edb827d2c08b0ca7d72311b78d574.json => query-a32d7ba43745226fd65328475731526e0b20ea6eeafeb937eb01cdc2cdfcb859.json} (68%) diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-d5661c7557cf3a8dee7cf799cd364d21d38edb827d2c08b0ca7d72311b78d574.json b/backend/.sqlx/query-a32d7ba43745226fd65328475731526e0b20ea6eeafeb937eb01cdc2cdfcb859.json similarity index 68% rename from backend/.sqlx/query-d5661c7557cf3a8dee7cf799cd364d21d38edb827d2c08b0ca7d72311b78d574.json rename to backend/.sqlx/query-a32d7ba43745226fd65328475731526e0b20ea6eeafeb937eb01cdc2cdfcb859.json index 907b140fdd..e30ebbe98b 100644 --- a/backend/.sqlx/query-d5661c7557cf3a8dee7cf799cd364d21d38edb827d2c08b0ca7d72311b78d574.json +++ b/backend/.sqlx/query-a32d7ba43745226fd65328475731526e0b20ea6eeafeb937eb01cdc2cdfcb859.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1", + "query": "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false AND archived = false ORDER BY created_at DESC LIMIT 1", "describe": { "columns": [ { @@ -19,5 +19,5 @@ false ] }, - "hash": "d5661c7557cf3a8dee7cf799cd364d21d38edb827d2c08b0ca7d72311b78d574" + "hash": "a32d7ba43745226fd65328475731526e0b20ea6eeafeb937eb01cdc2cdfcb859" } diff --git a/backend/windmill-dep-map/src/trigger_dependents.rs b/backend/windmill-dep-map/src/trigger_dependents.rs index d2e3fd56dd..f17d3863ac 100644 --- a/backend/windmill-dep-map/src/trigger_dependents.rs +++ b/backend/windmill-dep-map/src/trigger_dependents.rs @@ -68,7 +68,7 @@ pub async fn trigger_dependents_to_recompute_dependencies( let job_payload = match importer_kind.as_str() { "script" => match sqlx::query_scalar!( - "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1", + "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false AND archived = false ORDER BY created_at DESC LIMIT 1", importer_path, w_id ) From f035b538bbd786445526339f88be8f33a3628105 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 24 Mar 2026 14:26:56 +0100 Subject: [PATCH 003/153] feat: surface permissioned_as selector in trigger editor UI (#8475) * feat: surface permissioned_as selector in trigger editor UI Add OnBehalfOfSelector to TriggerEditorToolbar so users can see and control who a trigger runs as. Admins/deployers can preserve the current permissioned_as or pick a custom user; non-admins see the current value but options are disabled. Applies to all trigger types: schedule, kafka, http, websocket, postgres, nats, mqtt, sqs, gcp, and email. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: move permissioned_as selector from toolbar to config area Move OnBehalfOfSelector out of TriggerEditorToolbar (too cluttered) into a new PermissionedAsLine component rendered at the top of each trigger editor's config body. Lighter footprint, same functionality. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: show explicit warning when saving will change permissioned_as Use an Alert (warning/info) to clearly show who the trigger currently runs as and whether saving will change it. Non-admin users see a warning that it will switch to them. Admins see the OnBehalfOfSelector to preserve or pick a custom user. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: make permissioned_as line subtle instead of big alert box Replace the Alert component with a small inline text line using text-2xs. Shows warning arrow + yellow text only when saving will actually change the permissioned_as. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: split permissioned_as display for admin vs non-admin Admins see just "Permissioned as" label + the OnBehalfOfSelector (no duplicate username). Non-admins see the plain text line with warning arrow when it will change. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show warning for admins too when permissioned_as will change Admins now see a yellow warning next to the selector when their choice differs from the current permissioned_as value. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use consistent warning text for permissioned_as change Both admin and non-admin warnings now say "will change to on save" instead of using an arrow. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: bold permission strings in permissioned_as warnings Co-Authored-By: Claude Opus 4.6 (1M context) * fix: bold the non-editable permissioned_as value too Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove mono font from non-editable permissioned_as value Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add consistent bottom margin to permissioned_as line Co-Authored-By: Claude Opus 4.6 (1M context) * fix: consistent spacing for permissioned_as line Move PermissionedAsLine outside the gap-8 div in schedule editor and increase margin to mb-4 for consistent spacing across all trigger types. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/lib/components/flows/scheduleUtils.ts | 4 +- .../triggers/PermissionedAsLine.svelte | 84 +++++++++++++++++++ .../triggers/TriggerEditorToolbar.svelte | 4 +- .../email/EmailTriggerEditorInner.svelte | 20 ++++- .../lib/components/triggers/email/utils.ts | 4 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 28 ++++++- .../src/lib/components/triggers/gcp/utils.ts | 2 + .../triggers/http/RouteEditorInner.svelte | 34 ++++++-- .../src/lib/components/triggers/http/utils.ts | 4 +- .../kafka/KafkaTriggerEditorInner.svelte | 29 +++++-- .../lib/components/triggers/kafka/utils.ts | 4 +- .../mqtt/MqttTriggerEditorInner.svelte | 32 +++++-- .../src/lib/components/triggers/mqtt/utils.ts | 2 + .../nats/NatsTriggerEditorInner.svelte | 20 ++++- .../src/lib/components/triggers/nats/utils.ts | 2 + .../PostgresTriggerEditorInner.svelte | 20 ++++- .../lib/components/triggers/postgres/utils.ts | 2 + .../schedules/ScheduleEditorInner.svelte | 36 ++++++-- .../triggers/sqs/SqsTriggerEditorInner.svelte | 20 ++++- .../src/lib/components/triggers/sqs/utils.ts | 2 + .../WebsocketTriggerEditorInner.svelte | 28 ++++++- .../components/triggers/websocket/utils.ts | 4 +- 22 files changed, 342 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/components/triggers/PermissionedAsLine.svelte diff --git a/frontend/src/lib/components/flows/scheduleUtils.ts b/frontend/src/lib/components/flows/scheduleUtils.ts index d158756f22..71265f97f9 100644 --- a/frontend/src/lib/components/flows/scheduleUtils.ts +++ b/frontend/src/lib/components/flows/scheduleUtils.ts @@ -170,7 +170,9 @@ export async function saveScheduleFromCfg( tag: scheduleCfg.tag, paused_until: scheduleCfg.paused_until, cron_version: scheduleCfg.cron_version, - dynamic_skip: scheduleCfg.dynamic_skip + dynamic_skip: scheduleCfg.dynamic_skip, + permissioned_as: scheduleCfg.permissioned_as, + preserve_permissioned_as: scheduleCfg.preserve_permissioned_as } try { if (edit) { diff --git a/frontend/src/lib/components/triggers/PermissionedAsLine.svelte b/frontend/src/lib/components/triggers/PermissionedAsLine.svelte new file mode 100644 index 0000000000..23723b7833 --- /dev/null +++ b/frontend/src/lib/components/triggers/PermissionedAsLine.svelte @@ -0,0 +1,84 @@ + + +{#if permissionedAs && $workspaceStore} +
+ Permissioned as + {#if canPreserve} + + {#if willChange} + + will change from {permissionedAs} on save + {/if} + {:else} + {permissionedAs} + {#if willChange} + + will change to {effectivePermissionedAs} on save + {/if} + {/if} +
+{/if} diff --git a/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte b/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte index a09491e0ba..52c68aeed9 100644 --- a/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte +++ b/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte @@ -118,7 +118,7 @@ {trigger?.isDraft ? 'Deploy' : 'Update'} {#snippet text()} - + {#if !isDeployed} Deploy the runnable to enable trigger creation {:else if cloudDisabled} @@ -127,7 +127,7 @@ Enter a valid config to {trigger?.isDraft ? 'deploy' : 'update'} the trigger {/if} - {/snippet} + {/snippet} {/if} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index a33f14243b..9e0f58cd6c 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -20,6 +20,7 @@ import Label from '$lib/components/Label.svelte' import EmailTriggerEditorConfigSection from './EmailTriggerEditorConfigSection.svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { getHandlerType, handleConfigChange } from '../utils' import { untrack } from 'svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -75,6 +76,9 @@ let drawer = $state(undefined) let initialConfig: NewEmailTrigger | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandlerSelected: ErrorHandler = $state('slack') @@ -181,6 +185,9 @@ retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = cfg?.mode ?? 'enabled' + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function loadTrigger(defaultConfig?: Partial): Promise { @@ -236,7 +243,9 @@ error_handler_path, error_handler_args, retry, - mode + mode, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } return nCfg @@ -304,6 +313,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
{#if mode === 'suspended'} diff --git a/frontend/src/lib/components/triggers/email/utils.ts b/frontend/src/lib/components/triggers/email/utils.ts index 314990b1bb..05c15bcb81 100644 --- a/frontend/src/lib/components/triggers/email/utils.ts +++ b/frontend/src/lib/components/triggers/email/utils.ts @@ -30,7 +30,9 @@ export async function saveEmailTriggerFromCfg( error_handler_path: emailCfg.error_handler_path, error_handler_args: emailCfg.error_handler_path ? emailCfg.error_handler_args : undefined, mode: emailCfg.mode, - retry: emailCfg.retry + retry: emailCfg.retry, + permissioned_as: emailCfg.permissioned_as, + preserve_permissioned_as: emailCfg.preserve_permissioned_as } try { if (edit) { diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 6a11d6180b..1c13e74790 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -22,6 +22,7 @@ import GcpTriggerEditorConfigSection from './GcpTriggerEditorConfigSection.svelte' import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveGcpTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import { deepEqual } from 'fast-equals' @@ -57,6 +58,9 @@ let subscription_mode: SubscriptionMode = $state('create_update') let initialConfig: Record | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let base_endpoint = $derived(`${window.location.origin}${base}`) let auto_acknowledge_msg = $state(true) let ack_deadline: number | undefined = $state() @@ -202,6 +206,9 @@ auto_acknowledge_msg = cfg?.auto_acknowledge_msg ?? true ack_deadline = cfg?.ack_deadline errorHandlerSelected = getHandlerType(error_handler_path ?? '') + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function updateTrigger(): Promise { @@ -246,7 +253,9 @@ error_handler_args, retry, auto_acknowledge_msg, - ack_deadline + ack_deadline, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -369,6 +378,15 @@

Loading...

{:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
{#if mode === 'suspended'} @@ -454,9 +472,11 @@
{#snippet header()} - + {/snippet}
diff --git a/frontend/src/lib/components/triggers/gcp/utils.ts b/frontend/src/lib/components/triggers/gcp/utils.ts index ec8a93942e..b55d4ac50a 100644 --- a/frontend/src/lib/components/triggers/gcp/utils.ts +++ b/frontend/src/lib/components/triggers/gcp/utils.ts @@ -32,6 +32,8 @@ export async function saveGcpTriggerFromCfg( is_flow: cfg.is_flow, auto_acknowledge_msg: cfg.auto_acknowledge_msg, ack_deadline: cfg.ack_deadline, + permissioned_as: cfg.permissioned_as, + preserve_permissioned_as: cfg.preserve_permissioned_as, ...errorHandlerAndRetries } if (edit) { diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 272a0b707c..a0a6a95336 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -46,6 +46,7 @@ import RouteBodyTransformerOption from './RouteBodyTransformerOption.svelte' import TestingBadge from '../testingBadge.svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { getHandlerType, handleConfigChange } from '../utils' import autosize from '$lib/autosize' import { untrack } from 'svelte' @@ -122,6 +123,9 @@ let drawer = $state(undefined) let initialConfig: NewHttpTrigger | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let optionTabSelected: 'request_options' | 'error_handler' | 'retries' = $state('request_options') let errorHandlerSelected: ErrorHandler = $state('slack') @@ -315,6 +319,9 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function loadTrigger(defaultConfig?: Partial): Promise { @@ -388,7 +395,9 @@ description: routeDescription, error_handler_path, error_handler_args, - retry + retry, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } return nCfg @@ -481,6 +490,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
{#if mode === 'suspended'} @@ -697,11 +715,15 @@ {#if !is_static_website}
{#snippet header()} - + {/snippet}
diff --git a/frontend/src/lib/components/triggers/http/utils.ts b/frontend/src/lib/components/triggers/http/utils.ts index 1a00cfd081..a9ffca8e7e 100644 --- a/frontend/src/lib/components/triggers/http/utils.ts +++ b/frontend/src/lib/components/triggers/http/utils.ts @@ -61,7 +61,9 @@ export async function saveHttpRouteFromCfg( error_handler_path: routeCfg.error_handler_path, error_handler_args: routeCfg.error_handler_path ? routeCfg.error_handler_args : undefined, retry: routeCfg.retry, - mode: routeCfg.mode + mode: routeCfg.mode, + permissioned_as: routeCfg.permissioned_as, + preserve_permissioned_as: routeCfg.preserve_permissioned_as } try { if (edit) { diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 5f68f03705..dbf82de5db 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -15,6 +15,7 @@ import KafkaTriggersConfigSection from './KafkaTriggersConfigSection.svelte' import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveKafkaTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -87,6 +88,9 @@ let autoOffsetReset = $state('latest') let autoCommit = $state(true) let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let resetLoading = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandlerSelected: ErrorHandler = $state('slack') @@ -216,6 +220,9 @@ retry = cfg?.retry filters = cfg?.filters ?? [] errorHandlerSelected = getHandlerType(error_handler_path ?? '') + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function loadTrigger(defaultConfig?: Record): Promise { @@ -246,7 +253,9 @@ extra_perms: extra_perms, error_handler_path, error_handler_args, - retry + retry, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -414,6 +423,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
{#if description} {@render description()} @@ -531,11 +549,10 @@ Offsets will not be committed automatically. Use wmill.commit_kafka_offsets(trigger_path, topic, partition, offset) - in Python or wmill.commitKafkaOffsets(triggerPath, topic, partition, offset) in TypeScript with the values from the event payload. The consumer collects - all pending commits and commits the highest offset for each topic/partition - pair. + in Python or + wmill.commitKafkaOffsets(triggerPath, topic, partition, offset) in TypeScript + with the values from the event payload. The consumer collects all pending commits and + commits the highest offset for each topic/partition pair. {/if}
diff --git a/frontend/src/lib/components/triggers/kafka/utils.ts b/frontend/src/lib/components/triggers/kafka/utils.ts index 12ee49ac24..df05709d72 100644 --- a/frontend/src/lib/components/triggers/kafka/utils.ts +++ b/frontend/src/lib/components/triggers/kafka/utils.ts @@ -26,7 +26,9 @@ export async function saveKafkaTriggerFromCfg( filters: cfg.filters ?? [], auto_offset_reset: cfg.auto_offset_reset ?? 'latest', auto_commit: cfg.auto_commit ?? true, - ...errorHandlerAndRetries + ...errorHandlerAndRetries, + permissioned_as: cfg.permissioned_as, + preserve_permissioned_as: cfg.preserve_permissioned_as } try { if (edit) { diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index df5c7c21e1..7fa941b8ff 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -24,6 +24,7 @@ import MqttEditorConfigSection from './MqttEditorConfigSection.svelte' import type { Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveMqttTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -98,6 +99,9 @@ let isValid: boolean = $state(false) let initialConfig: Record | undefined = {} let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let errorHandlerSelected: ErrorHandler = $state('slack') let error_handler_path: string | undefined = $state() let error_handler_args: Record = $state({}) @@ -215,6 +219,9 @@ errorHandlerSelected = getHandlerType(error_handler_path ?? '') activateV5Options.topic_alias_maximum = Boolean(v5_config.topic_alias_maximum) activateV5Options.session_expiry_interval = Boolean(v5_config.session_expiry_interval) + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } catch (error) { sendUserToast(`Could not load mqtt trigger config: ${error.body}`, true) } @@ -251,7 +258,9 @@ is_flow, error_handler_path, error_handler_args, - retry + retry, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -392,6 +401,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
{#if description} {@render description()} @@ -471,10 +489,14 @@
{#snippet header()} - + {/snippet}
diff --git a/frontend/src/lib/components/triggers/mqtt/utils.ts b/frontend/src/lib/components/triggers/mqtt/utils.ts index 535c4caade..36b79d53a8 100644 --- a/frontend/src/lib/components/triggers/mqtt/utils.ts +++ b/frontend/src/lib/components/triggers/mqtt/utils.ts @@ -27,6 +27,8 @@ export async function saveMqttTriggerFromCfg( script_path: cfg.script_path, is_flow: cfg.is_flow, mode: cfg.mode, + permissioned_as: cfg.permissioned_as, + preserve_permissioned_as: cfg.preserve_permissioned_as, ...errorHandlerAndRetries } try { diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index a3c02c57dc..f02b8e37ab 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -14,6 +14,7 @@ import NatsTriggersConfigSection from './NatsTriggersConfigSection.svelte' import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveNatsTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -89,6 +90,9 @@ use_jetstream: false }) let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let isValid = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandlerSelected: ErrorHandler = $state('slack') @@ -201,6 +205,9 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function loadTrigger(defaultConfig?: Record): Promise { @@ -229,7 +236,9 @@ use_jetstream: natsCfg.use_jetstream, error_handler_path, error_handler_args, - retry + retry, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -376,6 +385,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
{#if description} {@render description()} diff --git a/frontend/src/lib/components/triggers/nats/utils.ts b/frontend/src/lib/components/triggers/nats/utils.ts index 4376bef354..67336e390b 100644 --- a/frontend/src/lib/components/triggers/nats/utils.ts +++ b/frontend/src/lib/components/triggers/nats/utils.ts @@ -25,6 +25,8 @@ export async function saveNatsTriggerFromCfg( consumer_name: cfg.consumer_name, subjects: cfg.subjects, use_jetstream: cfg.use_jetstream, + permissioned_as: cfg.permissioned_as, + preserve_permissioned_as: cfg.preserve_permissioned_as, ...errorHandlerAndRetries } try { diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 5ffe489a22..a4f460d96b 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -33,6 +33,7 @@ import { base } from '$lib/base' import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import TestingBadge from '../testingBadge.svelte' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' @@ -113,6 +114,9 @@ let basic_mode = $derived(tab === 'basic') let initialConfig: Record | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let creatingSlot: boolean = $state(false) let creatingPublication: boolean = $state(false) let pg14: boolean = $derived(postgresVersion.startsWith('14')) @@ -318,7 +322,9 @@ error_handler_path, error_handler_args, retry, - mode + mode, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } return cfg } @@ -339,6 +345,9 @@ retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = cfg?.mode ?? 'enabled' + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function loadTrigger(defaultConfig?: Record): Promise { @@ -553,6 +562,15 @@
{/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
{#if description} {@render description()} diff --git a/frontend/src/lib/components/triggers/postgres/utils.ts b/frontend/src/lib/components/triggers/postgres/utils.ts index 3c01b53152..dfd69fa3da 100644 --- a/frontend/src/lib/components/triggers/postgres/utils.ts +++ b/frontend/src/lib/components/triggers/postgres/utils.ts @@ -126,6 +126,8 @@ export async function savePostgresTriggerFromCfg( publication_name: config.publication_name, publication: config.publication, mode: config.mode, + permissioned_as: config.permissioned_as, + preserve_permissioned_as: config.preserve_permissioned_as, ...errorHandlerAndRetries } if (edit) { diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 55f825b40c..b8d4ee59f9 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -38,6 +38,7 @@ import { handleConfigChange } from '../utils' import TextInput from '$lib/components/text_input/TextInput.svelte' import { twMerge } from 'tailwind-merge' + import PermissionedAsLine from '../PermissionedAsLine.svelte' let { useDrawer = true, @@ -111,6 +112,9 @@ let isValid = $state(true) let allowSchedule = $derived(isValid && validCRON && script_path != '') let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) const saveDisabled = $derived( !allowSchedule || @@ -507,6 +511,9 @@ extraPerms = cfg.extra_perms ?? {} can_write = canWrite(cfg.path, cfg.extra_perms, $userStore) tag = cfg.tag + permissionedAs = cfg.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false loading = false } @@ -605,7 +612,9 @@ paused_until: paused_until, cron_version: cronVersion, extra_perms: extraPerms, - dynamic_skip: dynamicSkipPath + dynamic_skip: dynamicSkipPath, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -682,6 +691,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
@@ -913,12 +931,16 @@
{#snippet header()} - + {/snippet} {@render errorHandler()}
diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 1d9e1d783f..49b0a8182b 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -20,6 +20,7 @@ import Required from '$lib/components/Required.svelte' import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveSqsTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -88,6 +89,9 @@ let isValid = $state(false) let initialConfig: Record | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandlerSelected: ErrorHandler = $state('slack') let error_handler_path: string | undefined = $state() @@ -189,6 +193,9 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } catch (error) { sendUserToast(`Could not load SQS trigger config: ${error.body}`, true) } @@ -223,7 +230,9 @@ mode, error_handler_path, error_handler_args, - retry + retry, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -362,6 +371,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
{#if description} {@render description()} diff --git a/frontend/src/lib/components/triggers/sqs/utils.ts b/frontend/src/lib/components/triggers/sqs/utils.ts index 1481639f74..4bca536b2f 100644 --- a/frontend/src/lib/components/triggers/sqs/utils.ts +++ b/frontend/src/lib/components/triggers/sqs/utils.ts @@ -25,6 +25,8 @@ export async function saveSqsTriggerFromCfg( message_attributes: cfg.message_attributes, aws_auth_resource_type: cfg.aws_auth_resource_type, mode: cfg.mode, + permissioned_as: cfg.permissioned_as, + preserve_permissioned_as: cfg.preserve_permissioned_as, ...errorHandlerAndRetries } try { diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index 56d4591cd1..2eece07a11 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -31,6 +31,7 @@ import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveWebsocketTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -105,6 +106,9 @@ let showLoading = $state(false) let initialConfig: Record | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let isValid = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandlerSelected: ErrorHandler = $state('slack') @@ -234,6 +238,9 @@ retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = cfg?.mode ?? 'enabled' + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } function getSaveCfg() { @@ -250,7 +257,9 @@ error_handler_path, error_handler_args, retry, - mode + mode, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -438,6 +447,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
{#if mode === 'suspended'} @@ -688,9 +706,11 @@
{#snippet header()} - 0 } - ]} /> + 0 }]} + /> {/snippet}
diff --git a/frontend/src/lib/components/triggers/websocket/utils.ts b/frontend/src/lib/components/triggers/websocket/utils.ts index d71a41faa8..5cd27c3b72 100644 --- a/frontend/src/lib/components/triggers/websocket/utils.ts +++ b/frontend/src/lib/components/triggers/websocket/utils.ts @@ -29,7 +29,9 @@ export async function saveWebsocketTriggerFromCfg( url_runnable_args: triggerCfg.url_runnable_args, can_return_message: triggerCfg.can_return_message, can_return_error_result: triggerCfg.can_return_error_result, - ...errorHandlerAndRetries + ...errorHandlerAndRetries, + permissioned_as: triggerCfg.permissioned_as, + preserve_permissioned_as: triggerCfg.preserve_permissioned_as } try { if (edit) { From 5089a458819abbc6f241bc354bebb91520bd1a52 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 24 Mar 2026 14:27:09 +0100 Subject: [PATCH 004/153] feat: add summary field for native triggers (#8476) * feat: add summary field for native triggers (nextcloud, google) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add nullable to NativeTriggerData summary in openapi spec Co-Authored-By: Claude Opus 4.6 (1M context) * fix: include summary in native trigger search index Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...bc8a88935c613bd213b7156299811377db8e.json} | 7 +++--- ...ca5b322cc130a35a91f8b8854f5ebdf25ad2.json} | 12 +++++++--- ...5b1724c36031da39d53ae6c329a479bdf8aa.json} | 12 +++++++--- ...70e63dab12a1383a0855d105c061e4e4ca48.json} | 12 +++++++--- ...e15bb9edfa1dc9052f8a829e486b7334d708.json} | 7 +++--- ...0323000000_native_trigger_summary.down.sql | 1 + ...260323000000_native_trigger_summary.up.sql | 1 + .../tests/native_triggers.rs | 2 ++ backend/windmill-api/openapi.yaml | 12 ++++++++++ .../windmill-native-triggers/src/handler.rs | 2 ++ backend/windmill-native-triggers/src/lib.rs | 24 +++++++++++++------ cli/src/commands/trigger/trigger.ts | 4 ++++ .../native/NativeTriggerEditor.svelte | 24 ++++++++++++++++++- .../triggers/native/NativeTriggerTable.svelte | 17 ++++++++++--- .../lib/components/triggers/native/utils.ts | 5 ++-- frontend/src/lib/components/triggers/utils.ts | 9 +++++-- 16 files changed, 121 insertions(+), 30 deletions(-) rename backend/.sqlx/{query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json => query-1048d1c95270ce1f36c02bce31a2bc8a88935c613bd213b7156299811377db8e.json} (57%) rename backend/.sqlx/{query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json => query-15014ce696cf2af4f719a537a4e3ca5b322cc130a35a91f8b8854f5ebdf25ad2.json} (84%) rename backend/.sqlx/{query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json => query-6dafcc89668fb0e5740f23264b515b1724c36031da39d53ae6c329a479bdf8aa.json} (82%) rename backend/.sqlx/{query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json => query-b0775af41a9b54cce040bf37cae770e63dab12a1383a0855d105c061e4e4ca48.json} (67%) rename backend/.sqlx/{query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json => query-bf224f6441c36187f1402f9f01bfe15bb9edfa1dc9052f8a829e486b7334d708.json} (66%) create mode 100644 backend/migrations/20260323000000_native_trigger_summary.down.sql create mode 100644 backend/migrations/20260323000000_native_trigger_summary.up.sql diff --git a/backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json b/backend/.sqlx/query-1048d1c95270ce1f36c02bce31a2bc8a88935c613bd213b7156299811377db8e.json similarity index 57% rename from backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json rename to backend/.sqlx/query-1048d1c95270ce1f36c02bce31a2bc8a88935c613bd213b7156299811377db8e.json index 69af249a3f..577b2b3826 100644 --- a/backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json +++ b/backend/.sqlx/query-1048d1c95270ce1f36c02bce31a2bc8a88935c613bd213b7156299811377db8e.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_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 ", + "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 summary\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\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, summary = $8, error = NULL, updated_at = NOW()\n ", "describe": { "columns": [], "parameters": { @@ -21,10 +21,11 @@ "Varchar", "Bool", "Varchar", - "Jsonb" + "Jsonb", + "Varchar" ] }, "nullable": [] }, - "hash": "6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7" + "hash": "1048d1c95270ce1f36c02bce31a2bc8a88935c613bd213b7156299811377db8e" } diff --git a/backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json b/backend/.sqlx/query-15014ce696cf2af4f719a537a4e3ca5b322cc130a35a91f8b8854f5ebdf25ad2.json similarity index 84% rename from backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json rename to backend/.sqlx/query-15014ce696cf2af4f719a537a4e3ca5b322cc130a35a91f8b8854f5ebdf25ad2.json index a90b2b8398..66b8c431da 100644 --- a/backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json +++ b/backend/.sqlx/query-15014ce696cf2af4f719a537a4e3ca5b322cc130a35a91f8b8854f5ebdf25ad2.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_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 ", + "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 summary\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", "describe": { "columns": [ { @@ -62,6 +62,11 @@ "ordinal": 9, "name": "updated_at", "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "summary", + "type_info": "Varchar" } ], "parameters": { @@ -91,8 +96,9 @@ true, true, false, - false + false, + true ] }, - "hash": "bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb" + "hash": "15014ce696cf2af4f719a537a4e3ca5b322cc130a35a91f8b8854f5ebdf25ad2" } diff --git a/backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json b/backend/.sqlx/query-6dafcc89668fb0e5740f23264b515b1724c36031da39d53ae6c329a479bdf8aa.json similarity index 82% rename from backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json rename to backend/.sqlx/query-6dafcc89668fb0e5740f23264b515b1724c36031da39d53ae6c329a479bdf8aa.json index 492fffe8be..ea974dae42 100644 --- a/backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json +++ b/backend/.sqlx/query-6dafcc89668fb0e5740f23264b515b1724c36031da39d53ae6c329a479bdf8aa.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_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 ", + "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 summary\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": [ { @@ -62,6 +62,11 @@ "ordinal": 9, "name": "updated_at", "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "summary", + "type_info": "Varchar" } ], "parameters": { @@ -92,8 +97,9 @@ true, true, false, - false + false, + true ] }, - "hash": "1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce" + "hash": "6dafcc89668fb0e5740f23264b515b1724c36031da39d53ae6c329a479bdf8aa" } diff --git a/backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json b/backend/.sqlx/query-b0775af41a9b54cce040bf37cae770e63dab12a1383a0855d105c061e4e4ca48.json similarity index 67% rename from backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json rename to backend/.sqlx/query-b0775af41a9b54cce040bf37cae770e63dab12a1383a0855d105c061e4e4ca48.json index 660c855622..4109c7deaf 100644 --- a/backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json +++ b/backend/.sqlx/query-b0775af41a9b54cce040bf37cae770e63dab12a1383a0855d105c061e4e4ca48.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_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 ", + "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 nt.summary\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": [ { @@ -62,6 +62,11 @@ "ordinal": 9, "name": "updated_at", "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "summary", + "type_info": "Varchar" } ], "parameters": { @@ -94,8 +99,9 @@ true, true, false, - false + false, + true ] }, - "hash": "a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e" + "hash": "b0775af41a9b54cce040bf37cae770e63dab12a1383a0855d105c061e4e4ca48" } diff --git a/backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json b/backend/.sqlx/query-bf224f6441c36187f1402f9f01bfe15bb9edfa1dc9052f8a829e486b7334d708.json similarity index 66% rename from backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json rename to backend/.sqlx/query-bf224f6441c36187f1402f9f01bfe15bb9edfa1dc9052f8a829e486b7334d708.json index 706fa0c9ea..b40a643d50 100644 --- a/backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json +++ b/backend/.sqlx/query-bf224f6441c36187f1402f9f01bfe15bb9edfa1dc9052f8a829e486b7334d708.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "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 ", + "query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, summary = $8, 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": { @@ -21,10 +21,11 @@ } } }, - "Text" + "Text", + "Varchar" ] }, "nullable": [] }, - "hash": "40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e" + "hash": "bf224f6441c36187f1402f9f01bfe15bb9edfa1dc9052f8a829e486b7334d708" } diff --git a/backend/migrations/20260323000000_native_trigger_summary.down.sql b/backend/migrations/20260323000000_native_trigger_summary.down.sql new file mode 100644 index 0000000000..7c7e63101d --- /dev/null +++ b/backend/migrations/20260323000000_native_trigger_summary.down.sql @@ -0,0 +1 @@ +ALTER TABLE native_trigger DROP COLUMN IF EXISTS summary; diff --git a/backend/migrations/20260323000000_native_trigger_summary.up.sql b/backend/migrations/20260323000000_native_trigger_summary.up.sql new file mode 100644 index 0000000000..7989a6fcd3 --- /dev/null +++ b/backend/migrations/20260323000000_native_trigger_summary.up.sql @@ -0,0 +1 @@ +ALTER TABLE native_trigger ADD COLUMN summary VARCHAR(1000); diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 519e1f5262..e0a2e51ab5 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -422,6 +422,7 @@ async fn test_delete_integration_full_cascade(db: Pool) -> anyhow::Res "ext-1", &trigger_config, json!({"triggerType": "drive"}), + None, ) .await?; @@ -511,6 +512,7 @@ async fn test_cleanup_preserves_triggers(db: Pool) -> anyhow::Result<( "ext-1", &trigger_config, json!({"triggerType": "drive"}), + None, ) .await?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 930cb94949..65eede32b2 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -24592,6 +24592,10 @@ components: type: string nullable: true description: Error message if the trigger is in an error state + summary: + type: string + nullable: true + description: Short summary to be displayed when listed required: - external_id - workspace_id @@ -24626,6 +24630,10 @@ components: type: string nullable: true description: Error message if the trigger is in an error state + summary: + type: string + nullable: true + description: Short summary to be displayed when listed external_data: type: object description: Configuration data from the external service @@ -24718,6 +24726,10 @@ components: type: object description: Service-specific configuration (e.g., event types, filters) additionalProperties: true + summary: + type: string + nullable: true + description: Short summary to be displayed when listed required: - script_path - is_flow diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index b66a8ae3e0..cd6e1a8100 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -181,6 +181,7 @@ async fn create_native_trigger( &external_id, &config, service_config, + data.summary.as_deref(), ) .await?; @@ -304,6 +305,7 @@ async fn update_native_trigger_handler( &external_id, &config, service_config, + data.summary.as_deref(), ) .await?; diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 25234ff3f0..7853bb63e3 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -195,6 +195,7 @@ pub struct NativeTrigger { pub error: Option, pub created_at: DateTime, pub updated_at: DateTime, + pub summary: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -209,6 +210,7 @@ pub struct NativeTriggerData { pub script_path: String, pub is_flow: bool, pub service_config: C, + pub summary: Option, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] @@ -821,6 +823,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> external_id: &str, config: &NativeTriggerConfig, service_config: C, + summary: Option<&str>, ) -> Result<()> { use windmill_common::auth::hash_token; @@ -835,12 +838,13 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> script_path, is_flow, webhook_token_hash, - service_config + service_config, + summary ) VALUES ( - $1, $2, $3, $4, $5, $6, $7 + $1, $2, $3, $4, $5, $6, $7, $8 ) ON CONFLICT (external_id, workspace_id, service_name) - DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $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, summary = $8, error = NULL, updated_at = NOW() "#, external_id, workspace_id, @@ -849,6 +853,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> config.is_flow, webhook_token_hash, sqlx::types::Json(service_config) as _, + summary, ) .execute(db) .await?; @@ -863,6 +868,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres external_id: &str, config: &NativeTriggerConfig, service_config: Option<&RawValue>, + summary: Option<&str>, ) -> Result<()> { use windmill_common::auth::hash_token; @@ -871,7 +877,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres sqlx::query!( r#" UPDATE native_trigger - SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW() + SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, summary = $8, error = NULL, updated_at = NOW() WHERE workspace_id = $5 AND service_name = $6 @@ -884,6 +890,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres workspace_id, service_name as ServiceName, external_id, + summary, ) .execute(db) .await?; @@ -934,7 +941,8 @@ pub async fn get_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>( service_config, error, created_at, - updated_at + updated_at, + summary FROM native_trigger WHERE @@ -972,7 +980,8 @@ pub async fn get_native_trigger_by_script<'c, E: sqlx::Executor<'c, Database = P service_config, error, created_at, - updated_at + updated_at, + summary FROM native_trigger WHERE @@ -1018,7 +1027,8 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> nt.service_config, nt.error, nt.created_at, - nt.updated_at + nt.updated_at, + nt.summary FROM native_trigger nt WHERE diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index be645a83fa..11f68bea96 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -231,6 +231,7 @@ export async function pushNativeTrigger( is_flow: result.is_flow, service_config: result.service_config, error: result.error, + summary: result.summary, }; log.debug(`Native trigger ${serviceName}/${externalId} exists on remote`); } catch { @@ -243,6 +244,7 @@ export async function pushNativeTrigger( script_path: localTrigger.script_path, is_flow: localTrigger.is_flow, service_config: localTrigger.service_config, + summary: localTrigger.summary, }; if (remoteTrigger) { @@ -251,11 +253,13 @@ export async function pushNativeTrigger( script_path: localTrigger.script_path, is_flow: localTrigger.is_flow, service_config: localTrigger.service_config, + summary: localTrigger.summary, }; const remoteCompare = { script_path: remoteTrigger.script_path, is_flow: remoteTrigger.is_flow, service_config: remoteTrigger.service_config, + summary: remoteTrigger.summary, }; if (isSuperset(localCompare, remoteCompare)) { diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte index 7334f0a62e..b6e28875ba 100644 --- a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte @@ -11,6 +11,7 @@ import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' import { Button } from '$lib/components/common' + import TextInput from '$lib/components/text_input/TextInput.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import { Loader2, Save } from 'lucide-svelte' @@ -94,6 +95,7 @@ let initialScriptPath = $state('') let fixedScriptPath = $state('') let isFlow = $state(false) + let summary = $state('') let externalId = $state(null) let can_write = $state(true) let originalConfig = $state | undefined>(undefined) @@ -123,6 +125,7 @@ can_write = true originalConfig = undefined initialConfig = undefined + summary = '' } export function openRecreate(nativeTrigger: ExtendedNativeTrigger) { @@ -146,6 +149,7 @@ can_write = true originalConfig = undefined initialConfig = undefined + summary = nativeTrigger.summary ?? '' } export async function openEdit( @@ -182,6 +186,7 @@ scriptPath = fullTrigger.script_path initialScriptPath = fullTrigger.script_path can_write = canWrite(fullTrigger.script_path, {}, $userStore) + summary = fullTrigger.summary ?? '' externalData = fullTrigger.external_data // Apply default values if provided (for draft triggers) @@ -203,7 +208,8 @@ return { script_path: scriptPath, is_flow: isFlow, - service_config: serviceConfig + service_config: serviceConfig, + summary: summary !== '' ? summary : undefined } } @@ -386,6 +392,22 @@ {/if}
+
+
+ +
+
+ {#if !hideTarget}

diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte index 6314c12e82..8f8b84cd57 100644 --- a/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte +++ b/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte @@ -98,20 +98,31 @@ {@html trigger.marked} {:else} - {trigger.script_path} + {trigger.summary || trigger.script_path} {/if}

+ {#if trigger.summary} +
+ {trigger.script_path} +
+ {/if} {#if service === 'google'} {@const triggerType = trigger.service_config?.triggerType} {@const resourceName = trigger.service_config?.resourceName} {@const calendarName = trigger.service_config?.calendarName} -
+
{#if triggerType === 'calendar'} Calendar: {calendarName || trigger.service_config?.calendarId || ''} {:else} - Drive: {resourceName ? resourceName : trigger.service_config?.resourceId ? trigger.service_config.resourceId : 'All changes'} + Drive: {resourceName + ? resourceName + : trigger.service_config?.resourceId + ? trigger.service_config.resourceId + : 'All changes'} {/if}
{/if} diff --git a/frontend/src/lib/components/triggers/native/utils.ts b/frontend/src/lib/components/triggers/native/utils.ts index 34b1221b26..92082882e4 100644 --- a/frontend/src/lib/components/triggers/native/utils.ts +++ b/frontend/src/lib/components/triggers/native/utils.ts @@ -111,7 +111,7 @@ export function validateCommonFields(config: Record): Record Date: Tue, 24 Mar 2026 07:27:41 -0600 Subject: [PATCH 005/153] allow modern email TLDs in superadmin setup form (#8472) --- .../(root)/(logged)/user/(user)/instance_settings/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index d80e7fda85..66d9568c09 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -143,7 +143,7 @@ } } - const emailPattern = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/ + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/ let emailValid = $derived(emailPattern.test(newEmail)) let passwordValid = $derived(newPassword.length >= 2) let accountFormValid = $derived(emailValid && passwordValid) From 6d63d9973d2f5bfb86e691b00b5a6495b2ac305b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 13:31:06 +0000 Subject: [PATCH 006/153] chore(main): release 1.663.0 (#8465) * chore(main): release 1.663.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 25 ++ backend/Cargo.lock | 255 +++++++++--------- 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, 170 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07701ce7ce..c8aa1a9648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [1.663.0](https://github.com/windmill-labs/windmill/compare/v1.662.0...v1.663.0) (2026-03-24) + + +### Features + +* add summary field for native triggers ([#8476](https://github.com/windmill-labs/windmill/issues/8476)) ([5089a45](https://github.com/windmill-labs/windmill/commit/5089a458819abbc6f241bc354bebb91520bd1a52)) +* add typed request body to OpenAPI spec generation ([#8481](https://github.com/windmill-labs/windmill/issues/8481)) ([37ebaf4](https://github.com/windmill-labs/windmill/commit/37ebaf4d0ac342703498733f97778a552f979f6a)) +* **cli:** better stale scripts detection [#3](https://github.com/windmill-labs/windmill/issues/3) ([#8480](https://github.com/windmill-labs/windmill/issues/8480)) ([9643006](https://github.com/windmill-labs/windmill/commit/9643006f1e90b991b334bb58caf62301bc26d09d)) +* Debounce node ([#8324](https://github.com/windmill-labs/windmill/issues/8324)) ([5d1c54d](https://github.com/windmill-labs/windmill/commit/5d1c54d9b33d6ff6f2c98481a2740d1e7629cdfa)) +* surface permissioned_as selector in trigger editor UI ([#8475](https://github.com/windmill-labs/windmill/issues/8475)) ([f035b53](https://github.com/windmill-labs/windmill/commit/f035b538bbd786445526339f88be8f33a3628105)) + + +### Bug Fixes + +* clean up stale dependency map entries for renamed scripts ([#8492](https://github.com/windmill-labs/windmill/issues/8492)) ([47c0c36](https://github.com/windmill-labs/windmill/commit/47c0c363f4fc1d9af7efd07ea172e32989ce50d2)) +* **cli:** add Svelte 5 event delegation guidance and safe push to raw-app skill ([#8466](https://github.com/windmill-labs/windmill/issues/8466)) ([911df95](https://github.com/windmill-labs/windmill/commit/911df958e78d2dab9823dfa7d7e5c9824fc2d565)) +* Fix worker panic when job_isolation changed to unshare at runtime ([#8490](https://github.com/windmill-labs/windmill/issues/8490)) ([cbe47c0](https://github.com/windmill-labs/windmill/commit/cbe47c0b6c22f79452d020777e481ee26970f25b)) +* improve SQS retries ([3c8d351](https://github.com/windmill-labs/windmill/commit/3c8d351c9722a089133871019d27cf3bc3cdc159)) +* Move database manager SQL queries to backend ([#8306](https://github.com/windmill-labs/windmill/issues/8306)) ([aa30fd2](https://github.com/windmill-labs/windmill/commit/aa30fd252dcf40233d191c43a6293fb9feabf010)) +* prevent SQL injection in job query parameters ([#8494](https://github.com/windmill-labs/windmill/issues/8494)) ([54f5a19](https://github.com/windmill-labs/windmill/commit/54f5a19377e9df712e18f85f896e21b1776981ed)) +* respect NO_COLOR env variable for stdout log output ([#8483](https://github.com/windmill-labs/windmill/issues/8483)) ([f329ee7](https://github.com/windmill-labs/windmill/commit/f329ee7aaefbae0ad344743c40825440a936bd30)) +* show effective isolation level on workers page ([#8491](https://github.com/windmill-labs/windmill/issues/8491)) ([37886ed](https://github.com/windmill-labs/windmill/commit/37886edda1443293806a9b1b810196b72e076b12)) +* skip debounce arg accumulation when batch table is empty (CE) ([#8485](https://github.com/windmill-labs/windmill/issues/8485)) ([010753c](https://github.com/windmill-labs/windmill/commit/010753c73ac85237af50acadf9c08567b1bc993c)) +* stop_after_if with empty error_message prevents flow from stopping ([#8464](https://github.com/windmill-labs/windmill/issues/8464)) ([1503bf9](https://github.com/windmill-labs/windmill/commit/1503bf948e3340b8a6933d71885f8f2cb8dc1867)) + ## [1.662.0](https://github.com/windmill-labs/windmill/compare/v1.661.0...v1.662.0) (2026-03-20) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e40478cc4b..f044478b25 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -234,9 +234,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -2536,12 +2536,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.6.0" @@ -4887,19 +4881,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version 0.4.1", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "1.0.0" @@ -4927,6 +4908,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -7419,13 +7401,13 @@ dependencies = [ [[package]] name = "ipconfig" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +checksum = "2d72a21f6a71a6c4c3160e095e8925861f5119dd26ef71acee1b9146f74f76c8" dependencies = [ - "socket2 0.5.10", + "socket2 0.6.3", "widestring", - "windows-sys 0.48.0", + "windows-sys 0.61.2", "winreg", ] @@ -7446,9 +7428,9 @@ dependencies = [ [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -7538,7 +7520,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -7547,9 +7529,31 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "jobserver" @@ -8308,9 +8312,9 @@ dependencies = [ [[package]] name = "malachite" -version = "0.4.18" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6ecab92657eb234bfe98abd0b17920772c6b14ce69256950142e2eb36d000b" +checksum = "2fbdf9cb251732db30a7200ebb6ae5d22fe8e11397364416617d2c2cf0c51cb5" dependencies = [ "malachite-base", "malachite-nz", @@ -8331,11 +8335,11 @@ dependencies = [ [[package]] name = "malachite-bigint" -version = "0.2.0" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17703a19c80bbdd0b7919f0f104f3b0597f7de4fc4e90a477c15366a5ba03faa" +checksum = "d149aaa2965d70381709d9df4c7ee1fc0de1c614a4efc2ee356f5e43d68749f8" dependencies = [ - "derive_more 0.99.20", + "derive_more 1.0.0", "malachite", "num-integer", "num-traits", @@ -8610,9 +8614,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.14" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", @@ -8826,7 +8830,7 @@ version = "0.5.0+25.2.9519653" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -11663,7 +11667,7 @@ dependencies = [ "once_cell", "ring 0.17.14", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] @@ -11747,7 +11751,7 @@ dependencies = [ "rustls 0.23.35", "rustls-native-certs 0.8.3", "rustls-platform-verifier-android", - "rustls-webpki 0.103.9", + "rustls-webpki 0.103.10", "security-framework 3.6.0", "security-framework-sys", "webpki-root-certs 1.0.6", @@ -11795,9 +11799,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring 0.17.14", @@ -13877,12 +13881,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -14468,9 +14472,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow 1.0.0", ] @@ -15742,7 +15746,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-nats", @@ -15818,7 +15822,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15831,7 +15835,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "argon2", @@ -15972,7 +15976,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15995,7 +15999,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16008,7 +16012,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16034,7 +16038,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.662.0" +version = "1.663.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16044,7 +16048,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16061,7 +16065,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16084,7 +16088,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16107,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16123,7 +16127,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16143,7 +16147,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16163,7 +16167,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16177,7 +16181,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-nats", @@ -16205,7 +16209,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16230,7 +16234,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16248,7 +16252,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16260,6 +16264,7 @@ dependencies = [ "serde_json", "serde_yml", "sqlx", + "tracing", "url", "windmill-api-auth", "windmill-common", @@ -16269,7 +16274,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16289,7 +16294,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16319,7 +16324,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16346,7 +16351,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.662.0" +version = "1.663.0" dependencies = [ "lazy_static", "serde", @@ -16358,7 +16363,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.662.0" +version = "1.663.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16381,7 +16386,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16395,7 +16400,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.662.0" +version = "1.663.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16426,7 +16431,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.662.0" +version = "1.663.0" dependencies = [ "chrono", "lazy_static", @@ -16440,7 +16445,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16459,7 +16464,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.662.0" +version = "1.663.0" dependencies = [ "aes-gcm", "anyhow", @@ -16559,7 +16564,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.662.0" +version = "1.663.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16578,7 +16583,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.662.0" +version = "1.663.0" dependencies = [ "regex", "serde", @@ -16593,7 +16598,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16617,7 +16622,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "futures", @@ -16634,7 +16639,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.662.0" +version = "1.663.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16650,7 +16655,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -16671,7 +16676,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -16702,7 +16707,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-oauth2", @@ -16726,7 +16731,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-stream", @@ -16760,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "futures", @@ -16778,7 +16783,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.662.0" +version = "1.663.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16787,7 +16792,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "lazy_static", @@ -16799,7 +16804,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "serde_json", @@ -16811,7 +16816,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "gosyn", @@ -16823,7 +16828,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "lazy_static", @@ -16835,7 +16840,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "serde_json", @@ -16847,7 +16852,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "nu-parser", @@ -16858,7 +16863,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16869,7 +16874,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16881,7 +16886,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16892,7 +16897,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-recursion", @@ -16914,7 +16919,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "lazy_static", @@ -16928,7 +16933,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16945,7 +16950,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "lazy_static", @@ -16958,7 +16963,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "serde", @@ -16970,7 +16975,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "lazy_static", @@ -16988,7 +16993,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17004,7 +17009,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17020,7 +17025,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "serde", @@ -17031,7 +17036,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-recursion", @@ -17068,7 +17073,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "const_format", @@ -17106,7 +17111,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.662.0" +version = "1.663.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17117,7 +17122,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-recursion", @@ -17146,7 +17151,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17169,7 +17174,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -17202,7 +17207,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -17222,7 +17227,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -17256,7 +17261,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -17291,7 +17296,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -17314,7 +17319,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -17338,7 +17343,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-nats", @@ -17362,7 +17367,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -17397,7 +17402,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -17425,7 +17430,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-trait", @@ -17448,7 +17453,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17467,7 +17472,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.662.0" +version = "1.663.0" dependencies = [ "anyhow", "async-once-cell", @@ -17574,7 +17579,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.662.0" +version = "1.663.0" dependencies = [ "bytes", "futures", @@ -18189,12 +18194,12 @@ checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" [[package]] name = "winreg" -version = "0.50.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" dependencies = [ "cfg-if", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 744518e703..6dc821689c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.662.0" +version = "1.663.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.662.0" +version = "1.663.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 65eede32b2..8ef9caf936 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.662.0 + version: 1.663.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index b235377979..f93b61c6ee 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.662.0"; +export const VERSION = "v1.663.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 8804599f4a..83e9c1bbc8 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -68,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.662.0"; +export const VERSION = "1.663.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 10be9acea0..792e303b6e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.662.0", + "version": "1.663.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.662.0", + "version": "1.663.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 1dc86caae8..39bb3b6365 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.662.0", + "version": "1.663.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 5e0a6f2c6c..130d95a820 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.662.0" +wmill = ">=1.663.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index fa0e2e22cc..279b4589da 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.662.0 + version: 1.663.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index a4fa958a3d..c84a6d49f7 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.662.0' + ModuleVersion = '1.663.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 6e1bbf495b..92fbaf56d1 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.662.0" +version = "1.663.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 d1f9ea1b1a..5435328c88 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.662.0", + "version": "1.663.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 8a37e00e0c..bd66c04c51 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.662.0", + "version": "1.663.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index fd3cab66e3..eb4feec596 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.662.0 +1.663.0 From 7f27d996accb3c3b471d1c50df397867d89c738a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 15:16:10 +0000 Subject: [PATCH 007/153] fix: create parent dirs and accept 'python' alias in script bootstrap (#8497) Co-authored-by: Claude Opus 4.5 --- cli/src/commands/script/script.ts | 17 +++++++-- cli/test/standalone_commands.test.ts | 57 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 14f3155ff7..10015db776 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -1,7 +1,7 @@ import { GlobalOptions } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; -import { readFile, writeFile, stat } from "node:fs/promises"; +import { readFile, writeFile, stat, mkdir } from "node:fs/promises"; import { Buffer } from "node:buffer"; import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; @@ -1069,16 +1069,22 @@ async function get(opts: GlobalOptions & { json?: boolean }, path: string) { } } +const languageAliases: Record = { + python: "python3", +}; + async function bootstrap( opts: GlobalOptions & { summary: string; description: string }, scriptPath: string, - language: ScriptLanguage + language: ScriptLanguage | string ) { if (!validatePath(scriptPath)) { return; } - const scriptInitialCode = scriptBootstrapCode[language]; + const resolvedLanguage = (languageAliases[language] ?? language) as ScriptLanguage; + + const scriptInitialCode = scriptBootstrapCode[resolvedLanguage]; if (scriptInitialCode === undefined) { throw new Error("Language unknown"); } @@ -1086,7 +1092,7 @@ async function bootstrap( const config = await readConfigFile(); const extension = filePathExtensionFromContentType( - language, + resolvedLanguage, config.defaultTs ); const scriptCodeFileFullPath = scriptPath + extension; @@ -1118,6 +1124,9 @@ async function bootstrap( yamlOptions ); + const parentDir = path.dirname(scriptCodeFileFullPath); + await mkdir(parentDir, { recursive: true }); + await writeFile(scriptCodeFileFullPath, scriptInitialCode, { flag: 'wx', encoding: 'utf-8', }); diff --git a/cli/test/standalone_commands.test.ts b/cli/test/standalone_commands.test.ts index 106e4aaeb1..1d75680354 100644 --- a/cli/test/standalone_commands.test.ts +++ b/cli/test/standalone_commands.test.ts @@ -409,6 +409,63 @@ describe("script bootstrap command", () => { }); }); + test("accepts 'python' as alias for python3", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["script", "bootstrap", "f/test/py_alias_script", "python"], + tempDir + ); + + expect(result.code).toEqual(0); + + const codeStat = await stat(join(tempDir, "f/test/py_alias_script.py")); + expect(codeStat.isFile()).toBe(true); + + const metaStat = await stat( + join(tempDir, "f/test/py_alias_script.script.yaml") + ); + expect(metaStat.isFile()).toBe(true); + }); + }); + + test("creates parent directories automatically", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + + // Do NOT pre-create f/test — bootstrap should create it + const result = await backend.runCLICommand( + ["script", "bootstrap", "f/test/auto_dir_script", "bun"], + tempDir + ); + + expect(result.code).toEqual(0); + + const codeStat = await stat(join(tempDir, "f/test/auto_dir_script.ts")); + expect(codeStat.isFile()).toBe(true); + + const metaStat = await stat( + join(tempDir, "f/test/auto_dir_script.script.yaml") + ); + expect(metaStat.isFile()).toBe(true); + }); + }); + test("creates Go script files", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); From 3c34d19813752c7c3d718ac30a60266942b10909 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 15:41:39 +0000 Subject: [PATCH 008/153] escape env var values in nativets/bun JS string interpolation (#8500) Co-authored-by: Claude Opus 4.5 --- backend/windmill-worker/src/bun_executor.rs | 5 ++++- backend/windmill-worker/src/worker.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index fb07806fd6..f0ed8793a7 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -3008,7 +3008,10 @@ pub fn build_nativets_env_code( "const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}", reserved_variables .iter() - .map(|(k, v)| format!("process.env['{}'] = '{}';", k, v)) + .map(|(k, v)| { + let escaped = v.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n").replace('\r', "\\r"); + format!("process.env['{}'] = '{}';", k, escaped) + }) .collect::>() .join("\n") ) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 03e97a6ac1..b698f351c6 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -4493,7 +4493,10 @@ pub async fn run_language_executor( "const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}", reserved_variables .iter() - .map(|(k, v)| format!("const {} = '{}';\nprocess.env['{}'] = '{}';\n", k, v, k, v)) + .map(|(k, v)| { + let escaped = v.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n").replace('\r', "\\r"); + format!("const {} = '{}';\nprocess.env['{}'] = '{}';\n", k, escaped, k, escaped) + }) .collect::>() .join("\n")); From 2048a36376a9e931fdcef5c751d8f77918b1a94c Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:42:16 +0100 Subject: [PATCH 009/153] Fix select key bug (#8499) --- frontend/src/lib/components/select/SelectDropdown.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/select/SelectDropdown.svelte b/frontend/src/lib/components/select/SelectDropdown.svelte index 62a990bb46..d9fc2dcf70 100644 --- a/frontend/src/lib/components/select/SelectDropdown.svelte +++ b/frontend/src/lib/components/select/SelectDropdown.svelte @@ -117,7 +117,7 @@ ulClass )} > - {#each processedItems ?? [] as item, itemIndex (item.value)} + {#each processedItems ?? [] as item, itemIndex} {#if (item.__select_group && itemIndex === 0) || processedItems?.[itemIndex - 1]?.__select_group !== item.__select_group}
  • Date: Tue, 24 Mar 2026 12:00:32 -0400 Subject: [PATCH 010/153] fix: add GIT_SSL_CAINFO to tracing proxy env vars (#8502) Git uses libcurl with GnuTLS on Debian, which doesn't read SSL_CERT_FILE or CURL_CA_BUNDLE for CA trust. When the OTEL tracing proxy is enabled, git clone fails with "certificate signer not trusted" because it can't verify the proxy's MITM certificate. Adding GIT_SSL_CAINFO pointing to the proxy CA cert fixes this. Co-authored-by: Claude Opus 4.6 --- backend/windmill-worker/src/worker.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index b698f351c6..bdeea1c076 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -965,6 +965,7 @@ async fn get_otel_tracing_proxy_envs( TRACING_PROXY_CA_CERT_PATH.to_string(), ), ("CURL_CA_BUNDLE", TRACING_PROXY_CA_CERT_PATH.to_string()), + ("GIT_SSL_CAINFO", TRACING_PROXY_CA_CERT_PATH.to_string()), ("DENO_CERT", TRACING_PROXY_CA_CERT_PATH.to_string()), ]) } From 8cfaa91d43acd821ce79dcb2e179ce2590c3386b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 16:01:18 +0000 Subject: [PATCH 011/153] update cli freshness --- cli/src/guidance/skills.ts | 13 +++---------- system_prompts/auto-generated/cli/cli-commands.md | 2 ++ system_prompts/auto-generated/prompts.ts | 2 ++ .../auto-generated/skills/cli-commands/SKILL.md | 2 ++ 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 45279e631f..6872b610ef 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -4602,18 +4602,9 @@ Tell the user they can run these commands (do NOT run them yourself): | \`wmill app dev\` | Start dev server with live reload | | \`wmill app generate-agents\` | Refresh AGENTS.md and DATATABLES.md | | \`wmill app generate-locks\` | Generate lock files for backend runnables | -| \`wmill sync push --extra-includes "f//.raw_app/**" --yes\` | Deploy this specific raw app to Windmill (never do a blanket \`wmill sync push\`) | +| \`wmill sync push\` | Deploy app to Windmill | | \`wmill sync pull\` | Pull latest from Windmill | -## Svelte 5 Event Handling - -When building Svelte 5 raw apps, be aware of event delegation: - -- The Svelte runtime version in \`node_modules/svelte\` **must match** the compiler version used by \`wmill sync push\`. If you get \`$.delegated is undefined\` errors at runtime, run \`npm install svelte@latest\` in the raw app folder and re-push. -- \`onclick\` on \`
    \`, \`\`, and other non-interactive elements uses Svelte's event delegation system. If the runtime doesn't support it, you'll get errors. -- \`onclick\` on \`
    {/if} {#if node.workflow_as_code_status}
    -
    Workflow timeline
    +
    Workflow timeline
    flowModuleSchemaMap?.deleteMultiple(resolvedModuleIds)} onDuplicateSelected={() => flowModuleSchemaMap?.duplicateMultiple(resolvedModuleIds)} onMoveSelected={() => flowModuleSchemaMap?.moveMultiple(resolvedModuleIds)} + onCreateGroup={() => flowModuleSchemaMap?.createGroup(selectionManager.selectedIds)} {canMoveSelected} resolvedCount={resolvedModuleIds.length} /> diff --git a/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte b/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte index 7efb44b82d..1b54d83928 100644 --- a/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte @@ -3,8 +3,8 @@ import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte' import { Button } from '$lib/components/common' import DropdownV2 from '$lib/components/DropdownV2.svelte' - import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte' - import { StickyNote, Move, Copy, Trash2 } from 'lucide-svelte' + import { getGroupEditorContext } from '$lib/components/graph/groupEditor.svelte' + import { Group, Move, Copy, Trash2 } from 'lucide-svelte' import type { Item } from '$lib/utils' interface Props { @@ -13,6 +13,7 @@ onDeleteSelected?: () => void onDuplicateSelected?: () => void onMoveSelected?: () => void + onCreateGroup?: () => void canMoveSelected?: boolean resolvedCount?: number } @@ -22,18 +23,14 @@ onDeleteSelected, onDuplicateSelected, onMoveSelected, + onCreateGroup, canMoveSelected = false, resolvedCount = 0 }: Props = $props() - const noteEditorContext = getNoteEditorContext() + const groupEditorContext = getGroupEditorContext() - function addGroupNote() { - if (selectionManager.selectedIds.length > 0 && noteEditorContext?.noteEditor) { - // Create the group note - noteEditorContext.noteEditor.createGroupNote(selectionManager.selectedIds) - } - } + let canCreateGroup = $derived(groupEditorContext?.canCreateGroup.val ?? false) let menuItems: Item[] = $derived([ { @@ -60,11 +57,11 @@ {#snippet action()}
    {#if resolvedCount > 0} diff --git a/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte b/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte index b0504a8055..51d1f07d3f 100644 --- a/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte +++ b/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte @@ -31,9 +31,22 @@ editor?.setCode(code) } + function validateGroups(groups: { start_id: string; end_id: string }[] | undefined) { + if (!groups) return + const seen = new Set() + for (const g of groups) { + const key = `${g.start_id}:${g.end_id}` + if (seen.has(key)) { + throw new Error(`Duplicate group: '${g.start_id}' → '${g.end_id}'`) + } + seen.add(key) + } + } + function apply() { try { const parsed = YAML.parse(code) + validateGroups(parsed.value?.groups) if (parsed.summary && typeof parsed.summary === 'string') { flowStore.val.summary = parsed.summary } @@ -59,7 +72,7 @@ initialCode = code sendUserToast('Changes applied') } catch (e) { - ;(sendUserToast('Error parsing yaml: ' + e), true) + sendUserToast('Error parsing yaml: ' + e, true) } } @@ -69,8 +82,12 @@ drawer?.toggleDrawer()}> {#snippet actions()} - - + + {/snippet} {#if flowStore.val} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index 8e0250cec8..e6a548b39a 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -192,10 +192,10 @@ !!id && !!$flowPropPickerConfig && !!pickableIds && Object.keys(pickableIds).includes(id) ) - let isDragging = $derived(!!moveManager?.dragging) + let isMoving = $derived(!!moveManager?.dragging || !!moveManager?.movingModuleId) const outputPickerVisible = $derived( - editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isDragging + editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isMoving ) const icon_render = $derived(icon) @@ -214,7 +214,7 @@ flowStore?.val?.value.failure_module )} - (editId = false)}> + (editId = false)}>
    {#snippet icon()} @@ -484,11 +482,10 @@ {/if}
    - {#if deletable && !isDragging} + {#if deletable && !isMoving} {#if maximizeSubflow !== undefined} {@render buttonMaximizeSubflow?.()} {/if} - {#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)} - {#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isDragging} + {#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isMoving}
    (hover = false)} > {#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible} -
    +
    {#if !testIsLoading}
    {/each} + + 0} + on:confirmed={() => { + affectedGroupsAction?.() + affectedGroupsPending = [] + affectedGroupsAction = undefined + affectedGroupsCancel = undefined + }} + on:canceled={() => { + affectedGroupsCancel?.() + affectedGroupsPending = [] + affectedGroupsAction = undefined + affectedGroupsCancel = undefined + }} + > + {#if affectedGroupsPending.length === 1} + {@const group = affectedGroupsPending[0]} +

    The group{group.summary ? ` "${group.summary}"` : ''} will be removed (empty or duplicate). + Are you sure you want to {affectedGroupsActionLabel} the step?

    + {:else} +

    The following groups will be removed (empty or duplicate):

    +
      + {#each affectedGroupsPending as group} +
    • {group.summary || `${group.start_id} → ${group.end_id}`}
    • + {/each} +
    +

    Are you sure you want to {affectedGroupsActionLabel} the step?

    + {/if} +
    { dependents = getDependentComponents(id, flowStore.val) - const cb = () => { - push(history, flowStore.val) - if (id === 'preprocessor') { + + if (id === 'preprocessor') { + const cb = () => { + push(history, flowStore.val) selectionManager.selectId('Input') flowStore.val.value.preprocessor_module = undefined - } else { - selectNextId(id) - removeAtId(flowStore.val.value.modules, id) + refreshStateStore(flowStore) + onDelete?.(id) + delete flowStateStore.val[id] } + if (Object.keys(dependents).length > 0) { + deleteCallback = cb + } else { + cb() + } + return + } + + const dsOpts = { displayState: groupDisplayState } + const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => { + const found = findInStructure(tree, id) + if (found) found.parentChildren.splice(found.index, 1) + }, dsOpts) + + const affectedGroups = [...emptiedGroups, ...duplicateGroups] + + const cb = () => { + push(history, flowStore.val) + selectNextId(id) + commit({ removeDuplicates: duplicateGroups.length > 0 }) refreshStateStore(flowStore) onDelete?.(id) delete flowStateStore.val[id] } - if (Object.keys(dependents).length > 0) { - deleteCallback = cb + const proceed = () => { + if (Object.keys(dependents).length > 0) { + deleteCallback = cb + } else { + cb() + } + } + + if (affectedGroups.length > 0) { + affectedGroupsPending = affectedGroups + affectedGroupsActionLabel = 'delete' + affectedGroupsAction = proceed } else { - cb() + proceed() } }} onInsert={async (detail) => { - { - let originalModules - let targetModules - if ( - detail.sourceId == 'Input' || - detail.targetId == 'Result' || - detail.kind == 'trigger' - ) { - targetModules = flowStore.val.value.modules + if (!flowStore.val.value.modules || !Array.isArray(flowStore.val.value.modules)) return + await tick() + + // --- MOVE --- + if (moveManager.movingModuleId) { + const movedIds = moveManager.movingIds ?? [moveManager.movingModuleId] + const movingId = moveManager.movingModuleId + + let mutated = false + const moveOpts = { displayState: groupDisplayState } + const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => { + let originalModules: FlowStructureNode[] | undefined + let targetModules: FlowStructureNode[] | undefined + + if (detail.sourceId == 'Input' || detail.targetId == 'Result') { + targetModules = tree + } + dfsStructure(tree, (node, parentArray) => { + if (matchStructureNode(node, movingId)) originalModules = parentArray + if (detail.branch && matchStructureNode(node, detail.branch.rootId)) { + targetModules = node.branches[detail.branch.branch]?.children + } else if ( + matchStructureNode(node, detail.sourceId ?? '') || + matchStructureNode(node, detail.targetId ?? '') + ) { + targetModules = parentArray + } + }) + + if (!originalModules || !targetModules) return + + if (movedIds.length > 1) { + const firstIndex = originalModules.findIndex((m) => + matchStructureNode(m, movedIds[0]) + ) + if (firstIndex < 0) return + const removedModules = originalModules.splice(firstIndex, movedIds.length) + let insertIndex = detail.index + if (originalModules === targetModules && firstIndex < detail.index) { + insertIndex -= movedIds.length + } + targetModules.splice(insertIndex, 0, ...removedModules) + } else { + const indexToRemove = originalModules.findIndex((m) => + matchStructureNode(m, movingId) + ) + if (indexToRemove < 0) return + const [removed] = originalModules.splice(indexToRemove, 1) + let insertIndex = detail.index + if (originalModules === targetModules && indexToRemove < detail.index) + insertIndex -= 1 + targetModules.splice(insertIndex, 0, removed) + } + mutated = true + }, moveOpts) + + if (!mutated) { + moveManager.clearMoving() + return } - dfs(flowStore.val.value.modules, (mod, modules, branches) => { - if (mod.id == moveManager.movingModuleId) { - originalModules = modules - } - if (detail.branch) { - if (mod.id == detail.branch.rootId) { - targetModules = branches[detail.branch.branch] - } - } else if (mod.id == detail.sourceId || mod.id == detail.targetId) { - targetModules = modules - } else if (mod.id == detail.agentId && mod.value.type === 'aiagent') { - targetModules = mod.value.tools - } - }) - if (flowStore.val.value.modules && Array.isArray(flowStore.val.value.modules)) { - await tick() - if (moveManager.movingModuleId) { - push(history, flowStore.val) - if (!originalModules || !targetModules) { - moveManager.clearMoving() - return - } - if (moveManager.movingIds && moveManager.movingIds.length > 1) { - // Multi-move: splice out all moving modules from their parent, insert at target - const firstIndex = originalModules.findIndex( - (m) => m.id === moveManager.movingIds?.[0] - ) - const removedModules = originalModules.splice( - firstIndex, - moveManager.movingIds.length - ) - let insertIndex = detail.index - if (originalModules === targetModules && firstIndex < detail.index) { - insertIndex -= moveManager.movingIds.length - } - targetModules.splice(insertIndex, 0, ...removedModules) - selectionManager.selectByIds(removedModules.map((m) => m.id)) - } else { - let indexToRemove = originalModules.findIndex((m) => moveManager.movingModuleId == m.id) - let [removedModule] = originalModules.splice(indexToRemove, 1) - // When moving within the same array, removal shifts subsequent indices down by 1 - let insertIndex = detail.index - if (originalModules === targetModules && indexToRemove < detail.index) { - insertIndex -= 1 - } - targetModules.splice(insertIndex, 0, removedModule) - selectionManager.selectId(removedModule.id) - } - moveManager.clearMoving() + const affectedGroups = [...emptiedGroups, ...duplicateGroups] + + const doMove = () => { + push(history, flowStore.val) + commit({ removeDuplicates: duplicateGroups.length > 0 }) + if (movedIds.length > 1) { + selectionManager.selectByIds(movedIds) } else { - if (detail.isPreprocessor) { - await insertNewPreprocessorModule( - flowStore, - flowStateStore, - detail.inlineScript, - detail.script - ) - selectionManager.selectId('preprocessor') - - if (detail.inlineScript?.instructions) { - dispatch('generateStep', { - moduleId: 'preprocessor', - lang: detail.inlineScript?.language, - instructions: detail.inlineScript?.instructions - }) - } - } else { - const index = (detail.agentId ? targetModules?.length : detail.index) ?? 0 - const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = detail.agentId - ? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind) - ? (detail.kind as SpecialToolKind) - : 'flowmoduleTool' - : undefined - - await insertNewModuleAtIndex( - targetModules, - index, - detail.kind, - detail.script, - detail.flow, - detail.inlineScript, - toolKind - ) - const id = targetModules[index].id - selectionManager.selectId(id) - - if (detail.inlineScript?.instructions) { - dispatch('generateStep', { - moduleId: id, - lang: detail.inlineScript?.language, - instructions: detail.inlineScript?.instructions - }) - } - if (detail.kind == 'trigger') { - await insertNewModuleAtIndex( - targetModules, - index + 1, - 'forloop', - undefined, - undefined, - undefined - ) - setExpr(targetModules[index + 1], `results.${id}`) - setScheduledPollSchedule(triggersState, triggersCount) - } - - if (detail.flow?.path) { - loadLastJob(detail.flow.path, id) - } else if (detail.script?.path) { - loadLastJob(detail.script?.path, id) - } - } - } - - if (['branchone', 'branchall'].includes(detail.kind)) { - await addBranch(targetModules[detail.index ?? 0].id) + selectionManager.selectId(movingId) } + moveManager.clearMoving() refreshStateStore(flowStore) dispatch('change') } + + if (affectedGroups.length > 0) { + affectedGroupsPending = affectedGroups + affectedGroupsActionLabel = 'move' + affectedGroupsAction = doMove + affectedGroupsCancel = () => moveManager.clearMoving() + } else { + doMove() + } + return } + + // --- INSERT --- + if (detail.isPreprocessor) { + await insertNewPreprocessorModule( + flowStore, + flowStateStore, + detail.inlineScript, + detail.script + ) + selectionManager.selectId('preprocessor') + if (detail.inlineScript?.instructions) { + dispatch('generateStep', { + moduleId: 'preprocessor', + lang: detail.inlineScript?.language, + instructions: detail.inlineScript?.instructions + }) + } + refreshStateStore(flowStore) + dispatch('change') + return + } + + push(history, flowStore.val) + + const isAgentInsert = !!detail.agentId + const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = isAgentInsert + ? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind) + ? (detail.kind as SpecialToolKind) + : 'flowmoduleTool' + : undefined + + // Agent tool inserts operate on the FlowModule's tools array directly + if (isAgentInsert) { + const agentMod = getAllModules(flowStore.val.value.modules).find( + (m) => m.id === detail.agentId + ) + if (agentMod && (agentMod.value as any).tools) { + const tools = (agentMod.value as any).tools as AgentTool[] + await insertNewModuleAtIndex( + tools, + tools.length, + detail.kind as InsertKind, + detail.script, + detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined, + detail.inlineScript, + toolKind + ) + const id = tools[tools.length - 1].id + selectionManager.selectId(id) + } + refreshStateStore(flowStore) + dispatch('change') + return + } + + // Regular module insert: create the module, then insert a leaf node via tree mutation + const module = await createNewModule( + detail.kind as InsertKind, + detail.script, + detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined, + detail.inlineScript + ) + const index = detail.index ?? 0 + const extraModules: FlowModule[] = [module] + + // For trigger inserts, also create the forloop module + let loopModule: FlowModule | undefined + if (detail.kind == 'trigger') { + loopModule = await createNewModule('forloop') + setExpr(loopModule, `results.${module.id}`) + extraModules.push(loopModule) + } + + proxy.applyTreeMutation( + (tree) => { + // Find target array in the snapshot + let targetArray: FlowStructureNode[] | undefined + if ( + detail.sourceId == 'Input' || + detail.targetId == 'Result' || + detail.kind == 'trigger' + ) { + targetArray = tree + } + dfsStructure(tree, (node, parentArray) => { + if (detail.branch && matchStructureNode(node, detail.branch.rootId)) { + targetArray = node.branches[detail.branch.branch]?.children + } else if ( + matchStructureNode(node, detail.sourceId ?? '') || + matchStructureNode(node, detail.targetId ?? '') + ) { + targetArray = parentArray + } + }) + if (!targetArray) targetArray = tree + + // Insert the structure node (correct kind for containers like branchone/branchall) + targetArray.splice(index, 0, moduleToStructureNode(module)) + + // For trigger: also insert the forloop node after it + if (loopModule) { + targetArray.splice(index + 1, 0, moduleToStructureNode(loopModule)) + } + }, + { extraModules, displayState: groupDisplayState } + ) + + selectionManager.selectId(module.id) + + if (detail.inlineScript?.instructions) { + dispatch('generateStep', { + moduleId: module.id, + lang: detail.inlineScript?.language, + instructions: detail.inlineScript?.instructions + }) + } + if (detail.kind == 'trigger') { + setScheduledPollSchedule(triggersState, triggersCount) + } + if (detail.flow?.path) { + loadLastJob(detail.flow.path, module.id) + } else if (detail.script?.path) { + loadLastJob(detail.script?.path, module.id) + } + + if (['branchone', 'branchall'].includes(detail.kind)) { + await addBranch(module.id) + } + refreshStateStore(flowStore) + dispatch('change') }} onNewBranch={async (id) => { if (id) { @@ -761,6 +973,17 @@ mod.id = newId } }) + const groups = flowStore.val.value.groups + if (groups) { + for (const group of groups) { + if (group.start_id === id) { + group.start_id = newId + } + if (group.end_id === id) { + group.end_id = newId + } + } + } flowStateStore.val[newId] = flowStateStore.val[id] delete flowStateStore.val[id] refreshStateStore(flowStore) diff --git a/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte b/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte index 798116b47d..49cbb56cc6 100644 --- a/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte +++ b/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte @@ -46,7 +46,7 @@
    , allNodes: Node[], allEdges: Edge[]) { + function computeGhost( + moduleId: string, + draggedNodeIds: Set, + allNodes: Node[], + allEdges: Edge[] + ) { // Use pre-computed draggedNodeIds when available (covers multi-select), // otherwise fall back to single-module subflow computation. let sfNodes: Node[] @@ -111,7 +116,15 @@ zoom: scale } - return { containerWidth, containerHeight, ghostNodes, ghostEdges, offsetX, offsetY, initialViewport } + return { + containerWidth, + containerHeight, + ghostNodes, + ghostEdges, + offsetX, + offsetY, + initialViewport + } } let isNearDrop = $derived(moveManager.nearestDropZone != null) @@ -128,7 +141,8 @@ class="fixed pointer-events-none z-[10001] flex items-center justify-center w-5 h-5 rounded-full shadow border border-border transition-colors duration-150 {isNearDrop ? 'bg-surface-accent-primary text-white' : 'bg-surface text-secondary'}" - style="left: {moveManager.ghostScreenX + CURSOR_INDICATOR_OFFSET}px; top: {moveManager.ghostScreenY + CURSOR_INDICATOR_OFFSET}px;" + style="left: {moveManager.ghostScreenX + + CURSOR_INDICATOR_OFFSET}px; top: {moveManager.ghostScreenY + CURSOR_INDICATOR_OFFSET}px;" >
    diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 853dbf5c54..6a4d208579 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -59,8 +59,22 @@ import AiToolNode, { computeAIToolNodes } from './renderers/nodes/AIToolNode.svelte' import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte' import NoteNode from './renderers/nodes/NoteNode.svelte' + import CollapsedGroupNode from './renderers/nodes/CollapsedGroupNode.svelte' + import GroupHeadNode from './renderers/nodes/GroupHeadNode.svelte' + import GroupEndNode from './renderers/nodes/GroupEndNode.svelte' import NoteTool from './NoteTool.svelte' import SelectionBoundingBox from './SelectionBoundingBox.svelte' + import GroupOverlay from './GroupOverlay.svelte' + import { + GroupDisplayState, + getGroupEditorContext, + groupKey, + type FlowGroup + } from './groupEditor.svelte' + import { buildStructureTree, computeGroupDepths, type FlowStructureNode } from './flowStructure' + import { stateSnapshot } from '$lib/svelte5Utils.svelte' + import { computeGroupModuleIds } from './groupDetectionUtils' + import { getAllModules } from '../flows/flowExplorer' import SelectionTool from './SelectionTool.svelte' import PaneContextMenu from './PaneContextMenu.svelte' import { SelectionManager } from './selectionUtils.svelte' @@ -72,6 +86,7 @@ import { compoundLayout } from './compoundLayout' import { deepEqual } from 'fast-equals' import type { AssetWithAltAccessType } from '../assets/lib' + import { computeNodeExtraSpace } from './nodeExtraSpace' import type { ModuleActionInfo } from '$lib/components/flows/flowDiff' import { setGraphContext } from './graphContext' import { computeNoteNodes } from './noteUtils.svelte' @@ -100,6 +115,8 @@ interface Props { success?: boolean | undefined modules?: FlowModule[] | undefined + groupedModules?: FlowStructureNode[] + groupError?: unknown failureModule?: FlowModule | undefined preprocessorModule?: FlowModule | undefined minHeight?: number @@ -124,7 +141,7 @@ workspace?: string editMode?: boolean allowSimplifiedPoll?: boolean - expandedSubflows?: Record + expandedSubflows?: Record isOwner?: boolean isRunning?: boolean individualStepTests?: boolean @@ -133,6 +150,8 @@ suspendStatus?: Record noteMode?: boolean notes?: FlowNote[] + groups?: FlowGroup[] + groupDisplayState?: GroupDisplayState chatInputEnabled?: boolean multiSelectEnabled?: boolean onDeleteMultiple?: (ids: string[]) => void @@ -152,6 +171,7 @@ script?: { path: string; summary: string; hash: string | undefined } flow?: { path: string; summary: string } kind: InsertKind + expandGroup?: { groupId: string; position: 'top' | 'bottom' } }) => Promise onNewBranch?: (id: string) => Promise onSelect?: (id: string | FlowModule) => void @@ -193,6 +213,8 @@ onSelectedIteration = undefined, success = undefined, modules = [], + groupedModules: groupedModulesProp = undefined, + groupError = undefined, failureModule = undefined, preprocessorModule = undefined, minHeight = 0, @@ -232,6 +254,8 @@ flowHasChanged = false, noteMode = false, notes = undefined, + groups = undefined, + groupDisplayState: groupDisplayStateProp = undefined, exitNoteMode = undefined, onNotePositionUpdate = undefined, chatInputEnabled = false, @@ -257,6 +281,9 @@ () => nodes ) + const groupDisplayState = + untrack(() => groupDisplayStateProp) ?? new GroupDisplayState(() => groups ?? []) + // Runtime text height tracking for notes (not stored in FlowNote) let noteTextHeights = $state>({}) @@ -264,6 +291,8 @@ let paneContextMenu: PaneContextMenu | undefined = $state(undefined) let flowContainer: HTMLDivElement | undefined = $state(undefined) + // Hover tracking for group overlay + // Selection manager - create one if not provided let selectionManager = untrack(() => selectionManagerProp) || new SelectionManager() const selectedId = $derived(selectionManager.getSelectedId()) @@ -298,7 +327,9 @@ moveManager: untrack(() => moveManager), clearFlowSelection, yOffset, - diffManager + diffManager, + getFlowNodes: () => currentGraphNodeDeps, + groupDisplayState } as any) if (triggerContext && untrack(() => allowSimplifiedPoll)) { @@ -332,14 +363,36 @@ type NodeDep = { id: string parentIds?: string[] - data?: { assets?: AssetWithAltAccessType[] } + data?: { assets?: AssetWithAltAccessType[]; module?: any } } type NodePos = { position: { x: number; y: number } } - let lastNodes: [NodeDep[], (NodeDep & NodePos)[]] | undefined = undefined + let lastNodes: + | [NodeDep[], Map | undefined, (NodeDep & NodePos)[]] + | undefined = undefined + let currentGraphNodeDeps: { id: string; parentIds?: string[] }[] = $state([]) - function layoutNodes(nodes: NodeDep[]): (NodeDep & NodePos)[] { - let lastResult = lastNodes?.[1] - if (lastResult && deepEqual(nodes, lastNodes?.[0])) { + // Keep canCreateGroup in sync for consumers (SelectionBoundingBox, FlowSelectionPanel, etc.) + const groupEditorCtx = getGroupEditorContext() + + $effect(() => { + if (!groupEditorCtx) return + const ids = selectionManager.selectedIds + groupEditorCtx.canCreateGroup.val = + ids.length >= 1 && groupEditorCtx.groupEditor.canCreateGroup(ids, currentGraphNodeDeps) + }) + + let lastGroupDimensions: Map | undefined = undefined + + function layoutNodes( + nodes: NodeDep[], + nodeExtraSpace?: Map + ): (NodeDep & NodePos)[] { + let lastResult = lastNodes?.[2] + if ( + lastResult && + deepEqual(nodes, lastNodes?.[0]) && + deepEqual(nodeExtraSpace, lastNodes?.[1]) + ) { console.debug('layoutNodes', 'same nodes') return lastResult } @@ -352,16 +405,23 @@ seenId.push(n.id) } - // Run recursive compound layout - const { positions, bbox } = compoundLayout(nodes, { - nodeWidth: NODE.width, - nodeHeight: NODE.height, - gapH: NODE.gap.horizontal, - gapV: NODE.gap.vertical - }) + // Run recursive compound layout with pre-computed extra space + const layoutResult = compoundLayout( + nodes, + { + nodeWidth: NODE.width, + nodeHeight: NODE.height, + gapH: NODE.gap.horizontal, + gapV: NODE.gap.vertical + }, + nodeExtraSpace + ) + const { positions, bbox } = layoutResult + lastGroupDimensions = layoutResult.groupDimensions + + const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2 // Center horizontally - const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2 const newNodes = nodes.map((n) => ({ id: n.id, position: { @@ -370,7 +430,7 @@ } })) - lastNodes = [nodes, newNodes] + lastNodes = [nodes, nodeExtraSpace, newNodes] return newNodes } @@ -414,13 +474,16 @@ }, expandSubflow: async (id: string, path: string) => { const flow = await FlowService.getFlowByPath({ workspace: workspace, path }) - expandedSubflows[id] = flow.value.modules + expandedSubflows[id] = { modules: flow.value.modules, groups: flow.value.groups } expandedSubflows = expandedSubflows }, minimizeSubflow: (id: string) => { delete expandedSubflows[id] expandedSubflows = expandedSubflows }, + expandGroup: (groupId: string) => { + groupDisplayState.expandGroup(groupId) + }, updateMock: (detail) => { onUpdateMock?.(detail) }, @@ -585,17 +648,37 @@ return } - // console.log('compute') + const graphNodeDeps = Object.values(graph.nodes).map((n) => ({ + id: n.id, + parentIds: n.parentIds, + data: { assets: (n.data as any).assets, module: (n.data as any).module } + })) + currentGraphNodeDeps = graphNodeDeps - let layoutedNodes = layoutNodes( - Object.values(graph.nodes).map((n) => ({ - id: n.id, - parentIds: n.parentIds, - data: { assets: (n.data as any).assets } - })) - ) - let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => ({ ...n, ...graph.nodes[n.id] })) + // Pre-compute extra space per node for assets, AI tools, group notes, group headers + const nodeExtraSpace = computeNodeExtraSpace(graphNodeDeps, { + showAssets: $showAssets ?? true, + showNotes, + notes, + noteTextHeights, + groupDisplayState, + insertable, + flowModuleStates + }) + // Layout with extra space baked into sugiyama + let layoutedNodes = layoutNodes(graphNodeDeps, nodeExtraSpace) + let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => { + const merged = { ...n, ...graph.nodes[n.id] } + // Augment group head nodes with wrapper dimensions from compound layout + if (graph.nodes[n.id]?.type === 'groupHead' && lastGroupDimensions?.has(n.id)) { + const dims = lastGroupDimensions.get(n.id)! + merged.data = { ...merged.data, wrapperWidth: dims.width, wrapperHeight: dims.height } + } + return merged + }) + + // Compute asset visual nodes (no position remapping) let assetNodesResult = $showAssets ? computeAssetNodes( newNodes.map((n) => ({ @@ -605,25 +688,17 @@ })) ) : undefined - if (assetNodesResult) { - newNodes = newNodes.map((n) => ({ - ...n, - position: assetNodesResult.newNodePositions[n.id] - })) - } - let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates) - let nodesAfterAITools = newNodes.map((n) => ({ - ...n, - position: aiToolNodesResult.newNodePositions[n.id] - })) - let finalNodes = [ - ...nodesAfterAITools, + // Compute AI tool visual nodes (no position remapping) + let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates) + + let finalNodes: (Node & NodeLayout)[] = [ + ...newNodes, ...(assetNodesResult?.newAssetNodes ?? []), ...aiToolNodesResult.toolNodes ] - // Compute note nodes and positions + // Compute note nodes (no position remapping) let noteNodesResult = showNotes ? computeNoteNodes( finalNodes.map((n) => ({ @@ -644,14 +719,6 @@ ) : undefined - // Apply note positioning to nodes if notes are enabled - if (noteNodesResult) { - finalNodes = finalNodes.map((n) => ({ - ...n, - position: noteNodesResult.newNodePositions[n.id] || n.position - })) - } - // update nodes nodes = [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])] @@ -699,7 +766,10 @@ assetsOverflowed: AssetsOverflowedNode, aiTool: AiToolNode, newAiTool: NewAiToolNode, - note: NoteNode + note: NoteNode, + collapsedGroup: CollapsedGroupNode, + groupHead: GroupHeadNode, + groupEnd: GroupEndNode } as any const edgeTypes = { @@ -735,7 +805,41 @@ let graph = $derived.by(() => { moduleTracker.counter effectiveModuleActions - return graphBuilder( + currentGroups + + const collapsedGroupIds = new Set( + allGroups + .filter((g) => groupDisplayState.isRuntimeCollapsed(groupKey(g))) + .map((g) => groupKey(g)) + ) + + if (groupError) { + return { nodes: {}, edges: [], error: groupError } + } + + // Use provided structure tree (from proxy) or build locally (diff mode / read-only) + let gm: FlowStructureNode[] | undefined = groupedModulesProp + if (!gm) { + const allGroups = groups ?? [] + const graphGroups = allGroups.map((g) => ({ + ...g, + id: groupKey(g), + moduleIds: untrack(() => + computeGroupModuleIds(g.start_id, g.end_id, getAllModules(effectiveModules ?? [])) + ) + })) + try { + gm = buildStructureTree( + stateSnapshot(untrack(() => effectiveModules) ?? []) as FlowModule[], + graphGroups + ) + } catch (e) { + return { nodes: {}, edges: [], error: e } + } + } + + const result = graphBuilder( + gm, untrack(() => effectiveModules), { disableAi, @@ -767,16 +871,43 @@ untrack(() => selectedId), simplifiableFlow, triggerNode ? path : undefined, - expandedSubflows + expandedSubflows, + showNotes, + collapsedGroupIds ) + return { ...result, structureTree: gm } }) let hideAssetsToggle = $derived( $showAssets && Object.values(nodes).every((n) => n.type !== 'asset') ) - let hideNotesToggle = $derived(!notes || notes.length === 0) + let hideNotesToggle = $derived( + (!notes || notes.length === 0) && !(groups ?? []).some((g) => g.note != null) + ) + + let currentGroupDepths = $derived( + 'structureTree' in graph && graph.structureTree ? computeGroupDepths(graph.structureTree) : {} + ) + + // All groups including those from expanded subflows (for overlay rendering) + let allGroups = $derived.by(() => { + const base = groups ?? [] + const subflowGroups = Object.values(expandedSubflows).flatMap((sf) => sf.groups ?? []) + return subflowGroups.length > 0 ? [...base, ...subflowGroups] : base + }) + + // Track groups for re-layout when groups change + let currentGroups = $derived(groups ?? []) $effect(() => { - ;[graph, allowSimplifiedPoll, $showAssets, showNotes, noteManager.renderCount] + ;[ + graph, + allowSimplifiedPoll, + $showAssets, + showNotes, + noteManager.renderCount, + currentGroups, + groupDisplayState.renderCount + ] untrack(async () => { await updateStores() }) @@ -893,6 +1024,16 @@ } } + export function createGroupFromSelection(ids: string[]) { + if (groupEditorCtx?.groupEditor) { + groupEditorCtx.groupEditor.createGroup(ids, currentGraphNodeDeps) + tick().then(() => { + clearFlowSelection() + selectionManager.clearSelection() + }) + } + } + const modifierKey = isMac() ? 'Meta' : 'Control' @@ -909,7 +1050,7 @@ bind:this={flowContainer} > {#if graph?.error} -
    +
    {graph.error} @@ -1008,6 +1149,12 @@ /> {/if} + + @@ -1065,7 +1212,7 @@ try { localStorage.setItem( 'svelvet', - encodeState({ modules, failureModule, preprocessorModule, notes }) + encodeState({ modules, failureModule, preprocessorModule, notes, groups }) ) } catch (e) { console.error('error interacting with local storage', e) diff --git a/frontend/src/lib/components/graph/GroupActionBar.svelte b/frontend/src/lib/components/graph/GroupActionBar.svelte new file mode 100644 index 0000000000..1ae30395b5 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupActionBar.svelte @@ -0,0 +1,158 @@ + + +
    + {#if moveManager && moveModuleId} + moveManager.toggleMoving(moveModuleId!)} + /> + {/if} + {#if note == null} + + {/if} + + {#snippet buttonReplacement()} + + {/snippet} + {#snippet menu()} +
    + +
    +
    + {#each Object.values(NoteColor) as c (c)} + + {/each} +
    +
    + + +
    + onUpdateAutocollapse(e.detail)} + /> +
    + +
    + + + + + {#if onDeleteGroup} +
    + + + + {/if} +
    + {/snippet} +
    +
    diff --git a/frontend/src/lib/components/graph/GroupHeader.svelte b/frontend/src/lib/components/graph/GroupHeader.svelte new file mode 100644 index 0000000000..474d71c1a4 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupHeader.svelte @@ -0,0 +1,116 @@ + + + + +
    {}))} + title={collapsed ? 'Expand group' : 'Collapse group'} +> +
    + +
    +
    + {#if editingSummary} +
    + +
    + {:else} + {})) : undefined} + >{summary || PLACEHOLDER} + {/if} +
    +
    + + diff --git a/frontend/src/lib/components/graph/GroupHeaderBlock.svelte b/frontend/src/lib/components/graph/GroupHeaderBlock.svelte new file mode 100644 index 0000000000..089c0642a4 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupHeaderBlock.svelte @@ -0,0 +1,76 @@ + + + +
    (hovered = true)} + onmouseleave={() => (hovered = false)} +> + graphContext?.groupDisplayState?.toggleRuntimeCollapse(groupId)} + onSummaryUpdate={(text) => groupEditorContext?.groupEditor.updateSummary(groupId, text)} + /> + {#if showNotes && note != null} + graphContext?.groupDisplayState?.setNoteHeight(groupId, h)} + onNoteUpdate={(text) => groupEditorContext?.groupEditor.updateNote(groupId, text)} + /> + {/if} + {#if editMode} + (menuOpen = open)} + onAddNote={() => groupEditorContext?.groupEditor.addNote(groupId)} + onRemoveNote={() => groupEditorContext?.groupEditor.removeNote(groupId)} + onUpdateColor={(c) => groupEditorContext?.groupEditor.updateColor(groupId, c)} + onUpdateAutocollapse={(v) => groupEditorContext?.groupEditor.updateAutocollapse(groupId, v)} + onDeleteGroup={() => groupEditorContext?.groupEditor.deleteGroup(groupId)} + /> + {/if} +
    diff --git a/frontend/src/lib/components/graph/GroupModuleIcons.svelte b/frontend/src/lib/components/graph/GroupModuleIcons.svelte new file mode 100644 index 0000000000..3df1aecfaa --- /dev/null +++ b/frontend/src/lib/components/graph/GroupModuleIcons.svelte @@ -0,0 +1,182 @@ + + +
    + {#each displayModules as mod (mod.id)} + {@const selected = selectionManager.isNodeSelected(mod.id)} + {@const nodeState = flowModuleStates?.[mod.id]?.type} + {@const colorClasses = getNodeColorClasses(nodeState, selected)} + + {#snippet children()} + + +
    selectModule(mod)} + > +
    + +
    + {mod.id} +
    + {/snippet} + {#snippet text()} + {mod.id}: {moduleLabel(mod)} + {/snippet} +
    + {/each} + {#if overflowModules.length > 0} + {@const overflowColorClasses = getNodeColorClasses(overflowAggregateState, false)} + + {#snippet buttonReplacement()} +
    + +{overflowModules.length} +
    + {/snippet} + {#snippet menu()} +
    + {#each overflowModules as mod (mod.id)} + {@const nodeState = flowModuleStates?.[mod.id]?.type} + {@const colorClasses = getNodeColorClasses(nodeState, false)} + {@const selected = selectionManager.isNodeSelected(mod.id)} + + +
    selectModule(mod)} + > +
    + +
    + {moduleLabel(mod)} + {mod.id} +
    + {/each} +
    + {/snippet} +
    + {/if} +
    diff --git a/frontend/src/lib/components/graph/GroupNodeCard.svelte b/frontend/src/lib/components/graph/GroupNodeCard.svelte new file mode 100644 index 0000000000..dd69fcf0d0 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupNodeCard.svelte @@ -0,0 +1,165 @@ + + +
    +
    +
    + {#if modules && modules.length > 0} + + {:else} + + {/if} +
    + {#if editingSummary} + + {:else} + + + {})) : undefined} + >{summary || 'Group'} + {/if} +
    +
    + {#if stepCount != null} + + + {stepCount} node{stepCount !== 1 ? 's' : ''} + {/if} +
    + + {#if showNote} +
    + onHeightChange?.(h)} + onNoteUpdate={(text) => onNoteUpdate?.(text)} + /> +
    + {/if} +
    diff --git a/frontend/src/lib/components/graph/GroupNoteArea.svelte b/frontend/src/lib/components/graph/GroupNoteArea.svelte new file mode 100644 index 0000000000..1b4562e8d0 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupNoteArea.svelte @@ -0,0 +1,151 @@ + + + +
    +
    + {#if editing} +
    + + +
    + + {:else if note} + +
    {}) : undefined} + > + +
    + {:else} + +
    {}) : undefined} + > + Double click to edit the note +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/graph/GroupOverlay.svelte b/frontend/src/lib/components/graph/GroupOverlay.svelte new file mode 100644 index 0000000000..c15095bfb4 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupOverlay.svelte @@ -0,0 +1,90 @@ + + +{#each groups as group (groupKey(group))} + {@const bounds = groupBoundsMap[groupKey(group)]} + {#if bounds} + +
    +
    + {/if} +{/each} diff --git a/frontend/src/lib/components/graph/MiniFlowGraph.svelte b/frontend/src/lib/components/graph/MiniFlowGraph.svelte index fdb73745df..ab0c5042e8 100644 --- a/frontend/src/lib/components/graph/MiniFlowGraph.svelte +++ b/frontend/src/lib/components/graph/MiniFlowGraph.svelte @@ -1,6 +1,12 @@ - -{#if noteEditorContext?.noteEditor && selectedNodeIds.length > 1} - - {@render children()} - -{/if} diff --git a/frontend/src/lib/components/graph/NoteColorPicker.svelte b/frontend/src/lib/components/graph/NoteColorPicker.svelte index d17adb9317..ff3a1a276c 100644 --- a/frontend/src/lib/components/graph/NoteColorPicker.svelte +++ b/frontend/src/lib/components/graph/NoteColorPicker.svelte @@ -10,7 +10,11 @@ isOpen?: boolean } - let { selectedColor, onColorChange, isOpen = $bindable(false) }: Props = $props() + let { + selectedColor, + onColorChange, + isOpen = $bindable(false) + }: Props = $props() import { ViewportPortal, type Node } from '@xyflow/svelte' import { calculateNodesBoundsWithOffset } from './util' - import { StickyNote, Move, Copy, Trash2, EllipsisVertical } from 'lucide-svelte' + import { Move, Copy, Trash2, EllipsisVertical, Group } from 'lucide-svelte' import { Button } from '../common' import DropdownV2 from '../DropdownV2.svelte' - import { getNoteEditorContext } from './noteEditor.svelte' + import { getGroupEditorContext } from './groupEditor.svelte' import { getGraphContext } from './graphContext' import MoveHandleButton from './MoveHandleButton.svelte' import { tick } from 'svelte' @@ -36,18 +36,19 @@ let resolvedCount = $derived(resolvedModuleIds.length) - // Get NoteEditor context for group note creation - const noteEditorContext = getNoteEditorContext() + // Get GroupEditor context for group creation + const groupEditorContext = getGroupEditorContext() // Get Graph context for clearFlowSelection function and moveManager const graphContext = getGraphContext() const moveManager = graphContext?.moveManager - function handleAddGroupNote() { - if (selectedNodes.length > 0 && noteEditorContext?.noteEditor && graphContext) { - // Create the group note first - noteEditorContext.noteEditor.createGroupNote(selectedNodes) + let canCreateGroup = $derived(groupEditorContext?.canCreateGroup.val ?? false) + + function handleAddGroup() { + if (selectedNodes.length > 0 && groupEditorContext?.groupEditor && graphContext) { + const flowNodes = graphContext.getFlowNodes?.() ?? [] + groupEditorContext.groupEditor.createGroup(selectedNodes, flowNodes) - // Wait for next tick to ensure DOM updates tick().then(() => { graphContext?.clearFlowSelection?.() graphContext?.selectionManager.clearSelection() @@ -74,13 +75,13 @@ shortcut: isMac() ? '⌫' : 'Del', action: () => onDeleteSelected?.() }, - ...(noteEditorContext?.noteEditor + ...(groupEditorContext?.groupEditor ? [ { - displayName: 'Add note', - icon: StickyNote, - separatorTop: true, - action: handleAddGroupNote + displayName: 'Create group', + icon: Group, + action: handleAddGroup, + disabled: !canCreateGroup } ] : []) diff --git a/frontend/src/lib/components/graph/compoundLayout.ts b/frontend/src/lib/components/graph/compoundLayout.ts index 4bf3ddebda..c47aa62584 100644 --- a/frontend/src/lib/components/graph/compoundLayout.ts +++ b/frontend/src/lib/components/graph/compoundLayout.ts @@ -1,5 +1,6 @@ import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag' import { NODE } from './util' +import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte' type LayoutNode = { id: string @@ -14,7 +15,7 @@ type LayoutConstants = { } type CompoundGroup = { - type: 'branch' | 'loop' + type: 'branch' | 'loop' | 'group' headId: string endId: string branches: { @@ -27,9 +28,12 @@ type LayoutResult = { positions: Map bbox: { width: number; height: number } contentMinX: number + groupDimensions?: Map } const LOOP_INDENT = 25 +export const GROUP_PADDING = 16 +export const GROUP_TOP_PADDING = 32 /** * Detect compound groups from a flat list of node IDs. @@ -83,6 +87,18 @@ function detectGroups( endId: id, branches: [{ labelId: `${baseId}-start`, innerIds }] }) + } else if (baseId.startsWith('group:')) { + // Group pattern: group:{groupId} head + group:{groupId}-end + // Body is everything reachable from head to end + const innerIds = findInnerIds(baseId, id, nodeIds, childrenMap) + if (innerIds.length > 0) { + groups.push({ + type: 'group', + headId: baseId, + endId: id, + branches: [{ labelId: innerIds[0], innerIds: innerIds.slice(1) }] + }) + } } } @@ -230,6 +246,29 @@ function runSugiyama( * 5. Run sugiyama on the simplified graph * 6. Expand wrapper positions back to absolute positions */ +/** + * Build nodeSizes map for sugiyama from nodeExtraSpace. + * Each node's effective height = top + NODE.height + bottom. + */ +function buildNodeSizes( + nodeIds: string[], + constants: LayoutConstants, + nodeExtraSpace?: Map +): Map | undefined { + if (!nodeExtraSpace || nodeExtraSpace.size === 0) return undefined + const sizes = new Map() + for (const id of nodeIds) { + const extra = nodeExtraSpace.get(id) + if (extra && (extra.top > 0 || extra.bottom > 0 || extra.left > 0 || extra.right > 0)) { + sizes.set(id, { + width: constants.nodeWidth + extra.left + extra.right, + height: constants.nodeHeight + extra.top + extra.bottom + }) + } + } + return sizes.size > 0 ? sizes : undefined +} + const MAX_RECURSION_DEPTH = 50 function layoutLevel( @@ -237,7 +276,8 @@ function layoutLevel( allNodes: Map, constants: LayoutConstants, childrenMap: Map, - depth: number = 0 + depth: number = 0, + nodeExtraSpace?: Map ): LayoutResult { const positions = new Map() const nodeIdSet = new Set(nodeIds) @@ -256,8 +296,15 @@ function layoutLevel( const n = allNodes.get(id)! return { id, parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid)) } }) - const result = runSugiyama(flatNodes, constants) + const extraSizes = buildNodeSizes( + flatNodes.map((n) => n.id), + constants, + nodeExtraSpace + ) + const result = runSugiyama(flatNodes, constants, extraSizes) for (const [id, pos] of result.positions) { + const extra = nodeExtraSpace?.get(id) + if (extra) pos.y += extra.top positions.set(id, pos) } return { positions, bbox: { width: result.width, height: result.height }, contentMinX: 0 } @@ -322,7 +369,14 @@ function layoutLevel( const branchNodeIds = [branch.labelId, ...branch.innerIds] // Find sub-groups within this branch - const result = layoutLevel(branchNodeIds, allNodes, constants, childrenMap, depth + 1) + const result = layoutLevel( + branchNodeIds, + allNodes, + constants, + childrenMap, + depth + 1, + nodeExtraSpace + ) branchLayouts.push({ labelId: branch.labelId, @@ -349,6 +403,16 @@ function layoutLevel( maxBranchHeight = Math.max(0, ...branchLayouts.map((bl) => bl.bbox.height)) // head row + branch content + end row wrapperHeight = rowHeight + maxBranchHeight + rowHeight + } else if (group.type === 'group') { + // Group: body is centered with padding on all sides + const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth + const bodyHeight = branchLayouts[0]?.bbox.height ?? 0 + wrapperWidth = Math.max(bodyWidth + GROUP_PADDING * 2, constants.nodeWidth) + maxBranchHeight = bodyHeight + const headExtra = nodeExtraSpace?.get(group.headId) + const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING + // head row + body + bottom padding + wrapperHeight = groupHeadRow + bodyHeight + GROUP_PADDING } else { // Loop: body is indented const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth @@ -395,13 +459,30 @@ function layoutLevel( } // Step 5: Run sugiyama on flattened nodes - const sugResult = runSugiyama(flatNodes, constants, wrapperSizes) + // Merge wrapperSizes with nodeExtraSpace-derived sizes for non-group nodes + const extraSizes = buildNodeSizes( + flatNodes.map((n) => n.id), + constants, + nodeExtraSpace + ) + const mergedSizes = new Map() + if (extraSizes) { + for (const [id, size] of extraSizes) mergedSizes.set(id, size) + } + for (const [id, size] of wrapperSizes) mergedSizes.set(id, size) + const sugResult = runSugiyama( + flatNodes, + constants, + mergedSizes.size > 0 ? mergedSizes : undefined + ) // Step 6: Resolve absolute positions // First, set positions for regular (non-group) nodes + // Apply per-node y-offset from nodeExtraSpace so decorations above have room for (const [nid, pos] of sugResult.positions) { if (groupByHeadId.has(nid)) continue // Handle groups separately - positions.set(nid, { x: pos.x, y: pos.y }) + const extra = nodeExtraSpace?.get(nid) + positions.set(nid, { x: pos.x, y: pos.y + (extra?.top ?? 0) }) } // Now expand group wrappers into absolute positions @@ -411,9 +492,15 @@ function layoutLevel( const rowHeight = constants.nodeHeight + constants.gapV const isBranch = gl.group.type === 'branch' + const isGroup = gl.group.type === 'group' // Position the head node at the top-center of the wrapper - positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y }) + // Apply extra top padding so decorations above the head node have room + const headExtra = nodeExtraSpace?.get(headId) + positions.set(headId, { + x: wrapperPos.x, + y: wrapperPos.y + (headExtra?.top ?? 0) + }) if (isBranch) { // Reuse cached branchWidths and totalWidth @@ -441,6 +528,26 @@ function layoutLevel( x: wrapperPos.x, y: wrapperPos.y + rowHeight + maxBranchHeight + constants.gapV }) + } else if (isGroup) { + // Group: body is centered within wrapper (no x offset) + const headExtra = nodeExtraSpace?.get(gl.group.headId) + const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING + const bl = gl.branchLayouts[0] + if (bl) { + for (const [innerNodeId, innerPos] of bl.result.positions) { + positions.set(innerNodeId, { + x: wrapperPos.x + innerPos.x, + y: wrapperPos.y + groupHeadRow + innerPos.y + }) + } + } + + // Position end node below body + const bodyHeight = bl?.bbox.height ?? 0 + positions.set(gl.group.endId, { + x: wrapperPos.x, + y: wrapperPos.y + groupHeadRow + bodyHeight + GROUP_PADDING + }) } else { // Loop: position start, body, and end const bl = gl.branchLayouts[0] @@ -463,16 +570,34 @@ function layoutLevel( } } + // Collect group dimensions from this level and child layouts + const groupDimensions = new Map() + for (const [headId, gl] of groupLayouts) { + groupDimensions.set(headId, { width: gl.wrapperWidth, height: gl.wrapperHeight }) + // Propagate child groupDimensions from recursive branch layouts + for (const bl of gl.branchLayouts) { + if (bl.result.groupDimensions) { + for (const [childId, dims] of bl.result.groupDimensions) { + groupDimensions.set(childId, dims) + } + } + } + } + // Compute overall bbox (nodes + group wrapper extents) let minX = Infinity let maxX = -Infinity let minY = Infinity let maxY = -Infinity - for (const pos of positions.values()) { - minX = Math.min(minX, pos.x - constants.nodeWidth / 2) - maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2) - minY = Math.min(minY, pos.y) - maxY = Math.max(maxY, pos.y + constants.nodeHeight) + for (const [nid, pos] of positions) { + // Group end nodes are zero-height markers — skip them + if (nid.startsWith('group:') && nid.endsWith('-end')) continue + const extra = nodeExtraSpace?.get(nid) + minX = Math.min(minX, pos.x - constants.nodeWidth / 2 - (extra?.left ?? 0)) + maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2 + (extra?.right ?? 0)) + // Account for top decoration space above the node + minY = Math.min(minY, pos.y - (extra?.top ?? 0)) + maxY = Math.max(maxY, pos.y + constants.nodeHeight + (extra?.bottom ?? 0)) } // Account for group wrapper extents in bbox (e.g. LOOP_INDENT makes wrappers wider than nodes) for (const [headId, gl] of groupLayouts) { @@ -492,7 +617,12 @@ function layoutLevel( width: Math.max(bboxWidth, constants.nodeWidth), height: Math.max(bboxHeight, 0) } - return { positions, bbox: finalBbox, contentMinX } + return { + positions, + bbox: finalBbox, + contentMinX, + groupDimensions: groupDimensions.size > 0 ? groupDimensions : undefined + } } /** @@ -500,10 +630,16 @@ function layoutLevel( * * Takes the flat list of nodes and edges from graphBuilder and produces * absolute positions that account for compound structure (branches, loops). + * + * nodeExtraSpace: per-node top/bottom/left/right padding that should be allocated in layout. + * After layout, each node's y is shifted down by its top padding so decorations + * (assets, AI tools, group headers) have room above. Left/right padding widens the + * column allocated to the node so neighbors are pushed further away. */ export function compoundLayout( nodes: { id: string; parentIds?: string[] }[], - constants?: Partial + constants?: Partial, + nodeExtraSpace?: Map ): LayoutResult { const c: LayoutConstants = { nodeWidth: constants?.nodeWidth ?? NODE.width, @@ -528,7 +664,7 @@ export function compoundLayout( } const nodeIds = nodes.map((n) => n.id) - const result = layoutLevel(nodeIds, allNodes, c, childrenMap) + const result = layoutLevel(nodeIds, allNodes, c, childrenMap, 0, nodeExtraSpace) // Shift positions so minX=0 (left-aligned). // FlowGraphV2 centers with: xCenter = viewport/2 - bbox.width/2 diff --git a/frontend/src/lib/components/graph/flowStructure.test.ts b/frontend/src/lib/components/graph/flowStructure.test.ts new file mode 100644 index 0000000000..fce5071e80 --- /dev/null +++ b/frontend/src/lib/components/graph/flowStructure.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock modules that transitively import CSS/Monaco +vi.mock('monaco-editor', () => ({})) +vi.mock('@xyflow/svelte', () => ({})) +vi.mock('./renderers/nodes/AssetNode.svelte', () => ({ + assetDisplaysAsOutputInFlowGraph: () => false +})) +vi.mock('../modulesTest.svelte', () => ({})) + +import type { GraphGroup } from './groupEditor.svelte' +import type { FlowModule } from '$lib/gen' +import { + buildStructureTree, + flattenStructureIds, + deriveGroupsFromStructure, + collectLeafIds, + findInStructure +} from './flowStructure' + +function makeModule(id: string): FlowModule { + return { + id, + value: { type: 'rawscript', content: '', language: 'python3' } as any + } as FlowModule +} + +function makeBranchAll(id: string, branchInnerIds: string[][]): FlowModule { + return { + id, + value: { + type: 'branchall', + branches: branchInnerIds.map((ids) => ({ modules: ids.map((iid) => makeModule(iid)) })) + } as any + } as FlowModule +} + +function makeForloop(id: string, innerIds: string[]): FlowModule { + return { + id, + value: { + type: 'forloopflow', + modules: innerIds.map((iid) => makeModule(iid)), + iterator: { type: 'javascript', expr: '' } + } as any + } as FlowModule +} + +function makeGroup( + id: string, + start_id: string, + end_id: string, + moduleIds: string[] = [] +): GraphGroup { + return { id, start_id, end_id, moduleIds } +} + +describe('buildStructureTree', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + + it('builds structure tree for a valid group', () => { + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const result = buildStructureTree(modules, groups) + // Should have a group node + the remaining leaf 'c' + expect(result).toHaveLength(2) + expect(result[0].kind).toBe('group') + expect(result[0].id).toBe('g1') + expect(result[0].branches[0].children).toHaveLength(2) + expect(result[1].kind).toBe('leaf') + expect(result[1].id).toBe('c') + }) + + it('throws on duplicate group IDs', () => { + const groups = [makeGroup('g1', 'a', 'a', ['a']), makeGroup('g1', 'b', 'c', ['b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/duplicate group id.*g1/i) + }) + + it('throws on inverted range (start_id after end_id)', () => { + const groups = [makeGroup('g1', 'c', 'a', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/inverted range/i) + }) + + it('throws on partially overlapping groups', () => { + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b']), makeGroup('g2', 'b', 'c', ['b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/overlap without nesting/i) + }) + + it('throws when group start_id is a virtual node (Input)', () => { + const groups = [makeGroup('g1', 'Input', 'b', ['a', 'b'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('throws when group end_id is a virtual node (Result)', () => { + const groups = [makeGroup('g1', 'a', 'Result', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('throws when group references Trigger', () => { + const groups = [makeGroup('g1', 'Trigger', 'c', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('allows fully nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const result = buildStructureTree(mods, groups) + expect(result).toHaveLength(1) // outer group contains everything + expect(result[0].kind).toBe('group') + // Inner group should be nested + const outerChildren = result[0].branches[0].children + expect(outerChildren).toHaveLength(3) // a, inner-group, d + expect(outerChildren[1].kind).toBe('group') + expect(outerChildren[1].id).toBe('inner') + }) + + it('handles empty modules', () => { + const result = buildStructureTree([], []) + expect(result).toHaveLength(0) + }) + + it('handles container modules (forloop)', () => { + const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')] + const result = buildStructureTree(mods, []) + expect(result).toHaveLength(2) + expect(result[0].kind).toBe('forloopflow') + expect(result[0].branches).toHaveLength(1) + expect(result[0].branches[0].children).toHaveLength(2) + expect(result[0].branches[0].children[0].id).toBe('x') + }) + + it('handles groups inside containers', () => { + const mods = [makeForloop('loop', ['x', 'y', 'z'])] + const groups = [makeGroup('g1', 'x', 'y', ['x', 'y'])] + const result = buildStructureTree(mods, groups) + expect(result).toHaveLength(1) + expect(result[0].kind).toBe('forloopflow') + const innerChildren = result[0].branches[0].children + expect(innerChildren).toHaveLength(2) // group + z + expect(innerChildren[0].kind).toBe('group') + expect(innerChildren[0].id).toBe('g1') + }) + + it('throws when group spans parallel branches (branchall)', () => { + const mods = [ + makeModule('a'), + makeBranchAll('ba', [ + ['x', 'y'], + ['p', 'q'] + ]), + makeModule('c') + ] + const groups = [makeGroup('g1', 'x', 'q', ['x', 'q'])] + expect(() => buildStructureTree(mods, groups)).toThrow(/could not be resolved/) + }) +}) + +describe('flattenStructureIds', () => { + it('flattens a simple tree', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const ids = flattenStructureIds(tree) + expect(ids).toEqual(['a', 'b', 'c']) + }) + + it('flattens nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const tree = buildStructureTree(mods, groups) + const ids = flattenStructureIds(tree) + expect(ids).toEqual(['a', 'b', 'c', 'd']) + }) +}) + +describe('deriveGroupsFromStructure', () => { + it('derives group definitions with correct start/end', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const derived = deriveGroupsFromStructure(tree) + expect(derived).toHaveLength(1) + expect(derived[0].start_id).toBe('a') + expect(derived[0].end_id).toBe('b') + }) + + it('derives nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const tree = buildStructureTree(mods, groups) + const derived = deriveGroupsFromStructure(tree) + expect(derived).toHaveLength(2) + expect(derived[0].start_id).toBe('a') + expect(derived[0].end_id).toBe('d') + expect(derived[1].start_id).toBe('b') + expect(derived[1].end_id).toBe('c') + }) +}) + +describe('findInStructure', () => { + it('finds a leaf node', () => { + const modules = [makeModule('a'), makeModule('b')] + const tree = buildStructureTree(modules, []) + const found = findInStructure(tree, 'b') + expect(found).toBeDefined() + expect(found!.index).toBe(1) + }) + + it('finds a node inside a group', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const found = findInStructure(tree, 'b') + expect(found).toBeDefined() + expect(found!.index).toBe(1) + // parentChildren should be the group's branch children + expect(found!.parentChildren).toHaveLength(2) + }) + + it('finds a group node by group id', () => { + const modules = [makeModule('a'), makeModule('b')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const found = findInStructure(tree, 'g1') + expect(found).toBeDefined() + expect(found!.index).toBe(0) + }) +}) + +describe('collectLeafIds', () => { + it('collects all leaf module IDs including inside containers', () => { + const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')] + const tree = buildStructureTree(mods, []) + const ids = collectLeafIds(tree) + expect(ids).toEqual(['loop', 'x', 'y', 'c']) + }) +}) diff --git a/frontend/src/lib/components/graph/flowStructure.ts b/frontend/src/lib/components/graph/flowStructure.ts new file mode 100644 index 0000000000..e2a725bc85 --- /dev/null +++ b/frontend/src/lib/components/graph/flowStructure.ts @@ -0,0 +1,498 @@ +import type { FlowModule } from '$lib/gen' + +import type { FlowGroup, GraphGroup } from './groupEditor.svelte' +import { getContainerInnerArrays } from './groupEditor.svelte' +import { VIRTUAL_NODE_IDS } from './groupDetectionUtils' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ContainerKind = 'forloopflow' | 'whileloopflow' | 'branchone' | 'branchall' + +export type StructureBranch = { + label?: string + children: FlowStructureNode[] +} + +export type FlowStructureNode = { + /** FlowModule.id for modules, groupKey(g) for groups */ + id: string + kind: 'leaf' | 'group' | ContainerKind + /** Only present when kind === 'group' */ + group?: FlowGroup + /** Only present when kind === 'group' — flat module IDs for step count */ + moduleIds?: string[] + /** Child branches. leaf=[], group=[{children}], container=[{children}, ...] */ + branches: StructureBranch[] +} + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- +// Building the structure tree +// --------------------------------------------------------------------------- + +export function buildStructureTree( + modules: FlowModule[], + groups: GraphGroup[] +): FlowStructureNode[] { + const { items, consumed } = buildStructureTreeRecurse(modules, groups) + const unconsumed = groups.filter((g) => !consumed.has(g.id)) + if (unconsumed.length > 0) { + throw new Error( + `Group(s) ${unconsumed.map((g) => `'${g.id}'`).join(', ')} could not be resolved: ` + + `their start/end nodes do not belong to the same branch` + ) + } + return items +} + +export function moduleToStructureNode(mod: FlowModule): FlowStructureNode { + const innerArrays = getContainerInnerArrays(mod) + if (innerArrays.length === 0) { + return { id: mod.id, kind: 'leaf', branches: [] } + } + + const kind = (mod.value as any).type as ContainerKind + const branches: StructureBranch[] = innerArrays.map(({ get, label }) => ({ + label, + children: [] // filled later by recursion + })) + + return { id: mod.id, kind, branches } +} + +function buildStructureTreeRecurse( + modules: FlowModule[], + groups: GraphGroup[] +): { items: FlowStructureNode[]; consumed: Set } { + if (modules.length === 0) { + return { items: [], consumed: new Set() } + } + + const indexMap = new Map() + for (let i = 0; i < modules.length; i++) { + indexMap.set(modules[i].id, i) + } + + // Reject duplicate group IDs + const seenGroupIds = new Set() + for (const g of groups) { + if (seenGroupIds.has(g.id)) { + throw new Error(`Duplicate group id: '${g.id}'`) + } + seenGroupIds.add(g.id) + } + + // Reject groups referencing virtual nodes + for (const g of groups) { + if (VIRTUAL_NODE_IDS.has(g.start_id) || VIRTUAL_NODE_IDS.has(g.end_id)) { + throw new Error( + `Group '${g.id}' references virtual node: groups cannot include Input, Result, or Trigger` + ) + } + } + + // Partition: groups for this level vs rest + const levelGroups: GraphGroup[] = [] + const otherGroups: GraphGroup[] = [] + for (const g of groups) { + if (indexMap.has(g.start_id) && indexMap.has(g.end_id)) { + const s = indexMap.get(g.start_id)! + const e = indexMap.get(g.end_id)! + if (s > e) { + throw new Error( + `Group '${g.id}' has inverted range: start_id='${g.start_id}' (index ${s}) > end_id='${g.end_id}' (index ${e})` + ) + } + levelGroups.push(g) + } else { + otherGroups.push(g) + } + } + + // Validate no partial overlaps + for (let i = 0; i < levelGroups.length; i++) { + for (let j = i + 1; j < levelGroups.length; j++) { + const a = levelGroups[i] + const b = levelGroups[j] + const aStart = indexMap.get(a.start_id)! + const aEnd = indexMap.get(a.end_id)! + const bStart = indexMap.get(b.start_id)! + const bEnd = indexMap.get(b.end_id)! + + if (aEnd < bStart || bEnd < aStart) continue + if (aStart <= bStart && bEnd <= aEnd) continue + if (bStart <= aStart && aEnd <= bEnd) continue + + throw new Error(`Groups '${a.id}' and '${b.id}' overlap without nesting`) + } + } + + // Build grouped structure for this level + function build( + startIdx: number, + endIdx: number, + availableGroups: GraphGroup[] + ): FlowStructureNode[] { + const result: FlowStructureNode[] = [] + let i = startIdx + while (i <= endIdx) { + const candidates = availableGroups.filter((g) => { + const gStart = indexMap.get(g.start_id)! + const gEnd = indexMap.get(g.end_id)! + return gStart === i && gEnd <= endIdx + }) + candidates.sort((a, b) => { + const spanA = indexMap.get(a.end_id)! - indexMap.get(a.start_id)! + const spanB = indexMap.get(b.end_id)! - indexMap.get(b.start_id)! + return spanB - spanA + }) + + const group = candidates[0] + if (group) { + const gEnd = indexMap.get(group.end_id)! + const remaining = availableGroups.filter((g) => g.id !== group.id) + const innerNodes = build(i, gEnd, remaining) + + const moduleIds: string[] = [] + for (let k = i; k <= gEnd; k++) { + moduleIds.push(modules[k].id) + } + + result.push({ + id: group.id, + kind: 'group', + group: { + summary: group.summary, + note: group.note, + color: group.color, + autocollapse: group.autocollapse, + start_id: group.start_id, + end_id: group.end_id + }, + moduleIds, + branches: [{ children: innerNodes }] + }) + i = gEnd + 1 + } else { + result.push(moduleToStructureNode(modules[i])) + i++ + } + } + return result + } + + const result = build(0, modules.length - 1, levelGroups) + + // Recurse into containers with remaining unconsumed groups + const consumed = new Set(levelGroups.map((g) => g.id)) + let remaining = otherGroups + + function recurseIntoContainers(items: FlowStructureNode[]): void { + for (const item of items) { + if (item.kind === 'group') { + recurseIntoContainers(item.branches[0].children) + continue + } + if (item.branches.length === 0) continue + + // This is a container module — get inner FlowModule arrays and recurse + const modIdx = indexMap.get(item.id) + if (modIdx === undefined) continue + const mod = modules[modIdx] + + const innerArrays = getContainerInnerArrays(mod) + for (let bi = 0; bi < innerArrays.length; bi++) { + const inner = buildStructureTreeRecurse(innerArrays[bi].get(), remaining) + item.branches[bi] = { + label: item.branches[bi]?.label, + children: inner.items + } + for (const id of inner.consumed) consumed.add(id) + remaining = remaining.filter((g) => !inner.consumed.has(g.id)) + } + } + } + recurseIntoContainers(result) + + return { items: result, consumed } +} + +// --------------------------------------------------------------------------- +// Traversal utilities +// --------------------------------------------------------------------------- + +/** Generic DFS over the structure tree */ +export function dfsStructure( + nodes: FlowStructureNode[], + fn: (node: FlowStructureNode, parentArray: FlowStructureNode[]) => void +): void { + for (const node of nodes) { + fn(node, nodes) + for (const branch of node.branches) { + dfsStructure(branch.children, fn) + } + } +} + +/** Flatten to ordered module IDs (groups are transparent) */ +export function flattenStructureIds(nodes: FlowStructureNode[]): string[] { + const ids: string[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + ids.push(...flattenStructureIds(node.branches[0].children)) + } else { + ids.push(node.id) + } + } + return ids +} + +/** Collect leaf module IDs recursively (including inside containers) */ +export function collectLeafIds(nodes: FlowStructureNode[]): string[] { + const ids: string[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + ids.push(...collectLeafIds(node.branches[0].children)) + } else { + ids.push(node.id) + for (const branch of node.branches) { + ids.push(...collectLeafIds(branch.children)) + } + } + } + return ids +} + +// --------------------------------------------------------------------------- +// Finding nodes in the tree +// --------------------------------------------------------------------------- + +export type FindResult = { parentChildren: FlowStructureNode[]; index: number } + +export function findInStructure(nodes: FlowStructureNode[], id: string): FindResult | undefined { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + if (node.id === id) return { parentChildren: nodes, index: i } + for (const branch of node.branches) { + const found = findInStructure(branch.children, id) + if (found) return found + } + } + return undefined +} + +/** + * Match a structure node against a graph node ID. + * Handles group head/end IDs (group:X, group:X-end) and collapsed-group:X. + */ +export function matchStructureNode(node: FlowStructureNode, nodeId: string): boolean { + if (node.id === nodeId) return true + if (node.kind === 'group') { + return ( + nodeId === `group:${node.id}` || + nodeId === `group:${node.id}-end` || + nodeId === `collapsed-group:${node.id}` + ) + } + return false +} + +/** + * Find insert index using graph node IDs (handles group:X-end etc.). + * Returns the index OF the matched item (insert before it). + * For group-end nodes, returns index AFTER the group (insert after it). + */ +export function findInsertIndexByNodeId(items: FlowStructureNode[], targetNodeId: string): number { + // group-end: insert after the group + if (targetNodeId.startsWith('group:') && targetNodeId.endsWith('-end')) { + const groupId = targetNodeId.slice('group:'.length, -'-end'.length) + const idx = items.findIndex((n) => n.kind === 'group' && n.id === groupId) + return idx >= 0 ? idx + 1 : items.length + } + // Everything else: insert at the matched item's position + for (let i = 0; i < items.length; i++) { + if (matchStructureNode(items[i], targetNodeId)) return i + } + return items.length +} + +// --------------------------------------------------------------------------- +// Deriving groups from the structure tree +// --------------------------------------------------------------------------- + +export function deriveGroupsFromStructure(nodes: FlowStructureNode[]): FlowGroup[] { + const groups: FlowGroup[] = [] + for (const node of nodes) { + if (node.kind === 'group' && node.group) { + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length === 0) { + console.warn(`deriveGroupsFromStructure: skipping empty group "${node.id}"`) + continue + } + groups.push({ + ...node.group, + start_id: flatIds[0], + end_id: flatIds[flatIds.length - 1] + }) + // Recurse for nested groups + groups.push(...deriveGroupsFromStructure(node.branches[0].children)) + } else { + for (const branch of node.branches) { + groups.push(...deriveGroupsFromStructure(branch.children)) + } + } + } + return groups +} + +// --------------------------------------------------------------------------- +// Syncing structure back to FlowModule[] +// --------------------------------------------------------------------------- + +/** + * Reconstruct a FlowModule[] from the structure tree, looking up originals + * from moduleMap and patching container inner arrays to match the tree ordering. + */ +export function applyStructureToModules( + nodes: FlowStructureNode[], + moduleMap: Map +): FlowModule[] { + const result: FlowModule[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + // Groups are transparent — splice their children into this level + result.push(...applyStructureToModules(node.branches[0].children, moduleMap)) + } else { + const mod = moduleMap.get(node.id) + if (!mod) continue + + // Patch container inner arrays + if (node.branches.length > 0) { + const innerArrays = getContainerInnerArrays(mod) + for (let bi = 0; bi < innerArrays.length && bi < node.branches.length; bi++) { + innerArrays[bi].set(applyStructureToModules(node.branches[bi].children, moduleMap)) + } + } + + result.push(mod) + } + } + return result +} + +// --------------------------------------------------------------------------- +// Empty groups cleanup +// --------------------------------------------------------------------------- + +/** + * Walk the tree, remove group nodes that have no leaf modules, and return + * the removed groups. Mutates the input array in-place. + * Recurses depth-first so inner groups are cleaned before checking outer ones. + */ +export function removeEmptyGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const removed: FlowGroup[] = [] + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i] + if (node.kind === 'group' && node.group) { + // Recurse first — inner groups may become empty too + removed.push(...removeEmptyGroups(node.branches[0].children)) + if (flattenStructureIds(node.branches[0].children).length === 0) { + removed.push(node.group) + nodes.splice(i, 1) + } + } else { + for (const branch of node.branches) { + removed.push(...removeEmptyGroups(branch.children)) + } + } + } + return removed +} + +/** Walk the structure tree to compute nesting depth for each group (O(n)). */ +export function computeGroupDepths(tree: FlowStructureNode[]): Record { + const depths: Record = {} + function walk(nodes: FlowStructureNode[], groupDepth: number): void { + for (const node of nodes) { + if (node.kind === 'group') { + depths[node.id] = groupDepth + for (const branch of node.branches) { + walk(branch.children, groupDepth + 1) + } + } else { + for (const branch of node.branches) { + walk(branch.children, groupDepth) + } + } + } + } + walk(tree, 0) + return depths +} + +/** + * Find duplicate groups in the structure tree (same start_id:end_id after mutation). + * Returns the groups that should be removed (keeps the first, removes subsequent duplicates). + */ +export function findDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const duplicates: FlowGroup[] = [] + const seen = new Set() + + function walk(items: FlowStructureNode[]): void { + for (const node of items) { + if (node.kind === 'group' && node.group) { + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length > 0) { + const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}` + if (seen.has(key)) { + duplicates.push(node.group) + } else { + seen.add(key) + } + } + walk(node.branches[0].children) + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(nodes) + return duplicates +} + +/** Remove duplicate groups from the structure tree (keeps first occurrence). */ +export function removeDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const removed: FlowGroup[] = [] + const seen = new Set() + + function walk(items: FlowStructureNode[]): void { + for (let i = items.length - 1; i >= 0; i--) { + const node = items[i] + if (node.kind === 'group' && node.group) { + walk(node.branches[0].children) + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length > 0) { + const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}` + if (seen.has(key)) { + // Replace group node with its children (ungroup) + removed.push(node.group) + items.splice(i, 1, ...node.branches[0].children) + } else { + seen.add(key) + } + } + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(nodes) + return removed +} diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 192b5c0c34..4fd7641096 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -8,6 +8,14 @@ import { getFlowModuleAssets, type AssetWithAltAccessType } from '../assets/lib' import { assetDisplaysAsOutputInFlowGraph } from './renderers/nodes/AssetNode.svelte' import type { ModulesTestStates, ModuleTestState } from '../modulesTest.svelte' import type { ModuleActionInfo } from '$lib/components/flows/flowDiff' +import { + type FlowStructureNode, + collectLeafIds, + findInsertIndexByNodeId, + buildStructureTree +} from './flowStructure' +import { groupKey, type FlowGroup } from './groupEditor.svelte' +import { computeGroupModuleIds } from './groupDetectionUtils' export type InsertKind = | 'script' @@ -62,6 +70,7 @@ export type GraphEventHandlers = { simplifyFlow: (b: boolean) => void expandSubflow: (id: string, path: string) => void minimizeSubflow: (id: string) => void + expandGroup: (groupId: string) => void updateMock: (detail: { mock: FlowModule['mock']; id: string }) => void testUpTo: (id: string) => void editInput: (moduleId: string, key: string) => void @@ -111,6 +120,9 @@ export type FlowNode = | AssetsOverflowedN | AiToolN | NewAiToolN + | CollapsedGroupN + | GroupHeadN + | GroupEndN export type InputN = { type: 'input2' @@ -316,6 +328,48 @@ export type NewAiToolN = { } } +export type CollapsedGroupN = { + type: 'collapsedGroup' + data: { + groupId: string + summary: string | undefined + note: string | undefined + color: string | undefined + autocollapse: boolean | undefined + stepCount: number + modules: FlowModule[] + flowModuleStates: Record | undefined + flowJob: Job | undefined + isOwner: boolean + suspendStatus: Record + showNotes: boolean + editMode: boolean + eventHandlers: GraphEventHandlers + } +} + +export type GroupHeadN = { + type: 'groupHead' + data: { + groupId: string + summary: string | undefined + note: string | undefined + color: string | undefined + autocollapse: boolean | undefined + editMode: boolean + showNotes: boolean + eventHandlers: GraphEventHandlers + wrapperWidth?: number + } +} + +export type GroupEndN = { + type: 'groupEnd' + data: { + groupId: string + } +} + export function topologicalSort( nodes: { id: string; parentIds?: string[] }[] ): { id: string; parentIds?: string[] }[] { @@ -336,22 +390,8 @@ export function topologicalSort( return result.reverse() } -// input2: InputNode, -// module: ModuleNode, -// branchAllStart: BranchAllStart, -// branchAllEnd: BranchAllEndNode, -// forLoopEnd: ForLoopEndNode, -// forLoopStart: ForLoopStartNode, -// result: ResultNode, -// whileLoopStart: ForLoopStartNode, -// whileLoopEnd: ForLoopEndNode, -// branchOneStart: BranchOneStart, -// branchOneEnd: BranchOneEndNode, -// subflowBound: SubflowBound, -// noBranch: NoBranchNode, -// trigger: TriggersNode - export function graphBuilder( + structureTree: FlowStructureNode[], modules: FlowModule[] | undefined, extra: { disableAi: boolean @@ -383,11 +423,9 @@ export function graphBuilder( selectedId: string | undefined, simplifiableFlow: SimplifiableFlow | undefined, flowPathForTriggerNode: string | undefined, - expandedSubflows: Record - // triggerProps?: { - // path?: string - // flowIsSimplifiable?: boolean - // } + expandedSubflows: Record, + showNotes: boolean, + collapsedGroupIds: Set ): { nodes: { [key: string]: NodeLayout } edges: Edge[] @@ -403,7 +441,13 @@ export function graphBuilder( const nodes: NodeLayout[] = [] const edges: Edge[] = [] - function addNode(module: FlowModule) { + // Lookup map from module ID to the original reactive FlowModule objects. + const moduleMap = new Map() + for (const m of getAllModules(modules, failureModule)) { + moduleMap.set(m.id, m) + } + + function addNode(module: FlowModule, extraData?: Record) { const duplicated = nodes.find((n) => n.id === module.id) if (duplicated) { console.log('Duplicated node detected: ', module, duplicated) @@ -424,7 +468,8 @@ export function graphBuilder( isOwner: extra.isOwner, flowJob: extra.flowJob, assets: getFlowModuleAssets(module, extra.additionalAssetsMap), - moduleAction: extra.moduleActions?.[module.id] + moduleAction: extra.moduleActions?.[module.id], + ...extraData }, type: 'module', selectable: true @@ -483,14 +528,20 @@ export function graphBuilder( customId?: string type?: string subModules?: FlowModule[] + currentItems?: FlowStructureNode[] disableMoveIds?: string[] } ) { parents[targetId] = [...(parents[targetId] ?? []), sourceId] - const mods = options?.subModules ?? modules - - let index = mods?.findIndex((m) => m.id === targetId) ?? -1 + let index: number + if (options?.currentItems) { + index = findInsertIndexByNodeId(options.currentItems, targetId) + } else { + const mods = options?.subModules ?? modules + const found = mods?.findIndex((m) => m.id === targetId) ?? -1 + index = found >= 0 ? found : (mods?.length ?? 0) + } const visited = new Set() const recStack = new Set() @@ -514,8 +565,7 @@ export function graphBuilder( simplifiedTriggerView: simplifiableFlow?.simplifiedFlow, disableMoveIds: options?.disableMoveIds, enableTrigger: sourceId === 'Input', - // If the index is -1, it means that the target module is not in the modules array, so we set it to the length of the array - index: index >= 0 ? index : (mods?.length ?? 0), + index, ...extra, insertable: extra.insertable && !options?.disableInsert && prefix == undefined, shouldOffsetInsertBtnDueToAssetNode: nodeIdsWithOutputAssets.has(sourceId) @@ -591,7 +641,7 @@ export function graphBuilder( } function processModules( - modules: FlowModule[], + items: FlowStructureNode[], branch: { rootId: string; branch: number } | undefined, beforeNode: NodeLayout, nextNode: NodeLayout | undefined, @@ -600,31 +650,166 @@ export function graphBuilder( disableMoveIds: string[] = [], parentIndex?: string ) { + // For subflow prefix rewriting, clone modules into moduleMap with prefixed IDs + // (avoid mutating reactive originals which would trigger state_unsafe_mutation in $derived) if (prefix != undefined) { - modules.forEach((m) => { - if (!m['oid']) { - m['oid'] = m.id + items.forEach((item) => { + if (item.kind === 'group') return + const m = moduleMap.get(item.id) + if (m) { + const oid = m['oid'] ?? m.id + const newId = 'subflow:' + prefix + oid + const clone = { ...m, id: newId, oid } as FlowModule & { oid: string } + clone['oid'] = oid + moduleMap.set(newId, clone) + item.id = newId } - m.id = 'subflow:' + prefix + m['oid'] }) } let previousId: string | undefined = undefined - if (modules.length === 0) { + if (items.length === 0) { if (nextNode) { addEdge(beforeNode.id, nextNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } } else { - modules.forEach((module, index) => { + items.forEach((item, index) => { + // --- Group items --- + if (item.kind === 'group') { + const g = item.group! + const gId = item.id + + if (collapsedGroupIds.has(gId)) { + // Collapsed group: single node + const nodeId = `collapsed-group:${gId}` + const leafIds = collectLeafIds(item.branches[0].children) + nodes.push({ + id: nodeId, + data: { + groupId: gId, + summary: g.summary, + note: g.note, + color: g.color, + autocollapse: g.autocollapse, + stepCount: item.moduleIds?.length ?? 0, + modules: leafIds + .map((id) => moduleMap.get(id)) + .filter((m): m is FlowModule => !!m), + flowModuleStates: extra.flowModuleStates, + flowJob: extra.flowJob, + isOwner: extra.isOwner, + suspendStatus: extra.suspendStatus, + showNotes, + editMode: prefix == undefined && extra.editMode, + eventHandlers + }, + type: 'collapsedGroup', + selectable: false + }) + + // Wire: previous → collapsedGroup + if (index > 0 && previousId) { + addEdge(previousId, nodeId, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + previousId = nodeId + } else { + // Expanded group: head → recurse → end + const headId = `group:${gId}` + const endId = `group:${gId}-end` + const localDisableMoveIds = [...disableMoveIds, headId] + + const headNode: NodeLayout = { + id: headId, + data: { + groupId: gId, + summary: g.summary, + note: g.note, + color: g.color, + autocollapse: g.autocollapse, + editMode: prefix == undefined && extra.editMode, + showNotes, + eventHandlers + }, + type: 'groupHead', + selectable: false + } + + const endNode: NodeLayout = { + id: endId, + data: { + groupId: gId + }, + type: 'groupEnd', + selectable: false + } + + nodes.push(headNode) + nodes.push(endNode) + + // Wire: previous → headNode + if (index > 0 && previousId) { + addEdge(previousId, headId, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + // Recurse inner modules + processModules( + item.branches[0].children, + { rootId: headId, branch: 0 }, + headNode, + endNode, + simplifiedTriggerView, + prefix, + localDisableMoveIds, + parentIndex + ) + + previousId = endId + } + + // Shared first/last edge wiring for groups + if (index === 0) { + addEdge( + beforeNode.id, + collapsedGroupIds.has(gId) ? `collapsed-group:${gId}` : `group:${gId}`, + undefined, + prefix, + { + currentItems: items, + disableMoveIds, + disableInsert: simplifiedTriggerView + } + ) + } + + if (index === items.length - 1 && previousId && nextNode) { + addEdge(previousId, nextNode.id, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + return + } + + // --- Regular FlowModule items --- + const module = moduleMap.get(item.id) + if (!module) return const localDisableMoveIds = [...disableMoveIds, module.id] - // Add the edge between the previous node and the current one + // Inter-module edge: connect previous → current (expanded subflows handle their own) if (index > 0 && previousId && expandedSubflows[module.id] == undefined) { addEdge(previousId, module.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -700,7 +885,7 @@ export function graphBuilder( ) processModules( - branch.modules, + item.branches[branchIndex]?.children ?? [], { rootId: module.id, branch: branchIndex }, startNode, endNode, @@ -722,7 +907,7 @@ export function graphBuilder( id: `${module.id}-start`, data: { id: module.id, - module: module, + module: moduleMap.get(module.id) ?? module, simplifiedTriggerView, eventHandlers: eventHandlers, editMode: extra.editMode, @@ -759,7 +944,7 @@ export function graphBuilder( const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex processModules( - module.value.modules, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, startNode, endNode, @@ -798,7 +983,7 @@ export function graphBuilder( const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex processModules( - module.value.modules, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, startNode, endNode, @@ -825,21 +1010,6 @@ export function graphBuilder( } nodes.push(endNode) - // // Add default branch - // const defaultBranch: NodeLayout = { - // id: `${module.id}-default`, - // data: { - // offset: 0, - // label: 'Default', - // id: module.id, - // branchIndex: -1, - // eventHandlers: eventHandlers, - // branchOne: true, - // ...extra - // }, - // type: 'noBranch' - // } - const defaultBranch: NodeLayout = { id: `${module.id}-branch-default`, data: { @@ -863,7 +1033,7 @@ export function graphBuilder( }) processModules( - module.value.default, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, defaultBranch, endNode, @@ -899,7 +1069,7 @@ export function graphBuilder( }) processModules( - branch.modules, + item.branches[branchIndex + 1]?.children ?? [], { rootId: module.id, branch: branchIndex + 1 }, startNode, endNode, @@ -912,9 +1082,9 @@ export function graphBuilder( previousId = endNode.id } else { - let expanded = expandedSubflows[module.id] - if (expanded) { - expanded = $state.snapshot(expanded) + const expandedData = expandedSubflows[module.id] + if (expandedData) { + const expandedMods = $state.snapshot(expandedData.modules) as FlowModule[] const startId = `${module.id}` const idWithoutPrefix = module.id.startsWith('subflow:') ? module.id.substring(8) @@ -936,12 +1106,12 @@ export function graphBuilder( if (previousId) { addEdge(previousId, startNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } else { addEdge(beforeNode.id, startNode.id, undefined, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -962,8 +1132,20 @@ export function graphBuilder( nodes.push(endNode) + // Register expanded subflow modules so prefix rewriting finds + // the inner modules (not the parent flow's modules with same IDs) + for (const em of getAllModules(expandedMods)) { + moduleMap.set(em.id, em) + } + + const expandedGroups = (expandedData.groups ?? []).map((g) => ({ + ...g, + id: groupKey(g), + moduleIds: computeGroupModuleIds(g.start_id, g.end_id, getAllModules(expandedMods)) + })) + processModules( - expanded, + buildStructureTree(expandedMods, expandedGroups), undefined, startNode, endNode, @@ -981,15 +1163,15 @@ export function graphBuilder( if (index === 0 && expandedSubflows[module.id] == undefined) { addEdge(beforeNode.id, module.id, undefined, prefix, { - subModules: modules, + currentItems: items, disableMoveIds, disableInsert: simplifiedTriggerView }) } - if (index === modules.length - 1 && previousId && nextNode) { + if (index === items.length - 1 && previousId && nextNode) { addEdge(previousId, nextNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -997,10 +1179,12 @@ export function graphBuilder( } } + const topLevelItems = structureTree + if (simplifiableFlow?.simplifiedFlow === true && triggerNode) { - processModules(modules, undefined, triggerNode, undefined, true, undefined) + processModules(topLevelItems, undefined, triggerNode, undefined, true, undefined) } else { - processModules(modules, undefined, inputNode, resultNode, false, undefined) + processModules(topLevelItems, undefined, inputNode, resultNode, false, undefined) } if (failureModule) { diff --git a/frontend/src/lib/components/graph/graphContext.ts b/frontend/src/lib/components/graph/graphContext.ts index 8540982366..a6245769cf 100644 --- a/frontend/src/lib/components/graph/graphContext.ts +++ b/frontend/src/lib/components/graph/graphContext.ts @@ -4,6 +4,7 @@ import type { NoteManager } from './noteManager.svelte' import type { MoveManager } from './moveManager.svelte' import type { Writable } from 'svelte/store' import type { FlowDiffManager } from '../flows/flowDiffManager.svelte' +import type { GroupDisplayState } from './groupEditor.svelte' export type GraphContext = { selectionManager: SelectionManager @@ -14,6 +15,9 @@ export type GraphContext = { clearFlowSelection?: () => void yOffset?: number diffManager: FlowDiffManager + /** Current flow nodes for group validation (set by FlowGraphV2) */ + getFlowNodes?: () => { id: string; parentIds?: string[] }[] + groupDisplayState?: GroupDisplayState } const graphContextKey = 'FlowGraphContext' diff --git a/frontend/src/lib/components/graph/groupDetectionUtils.ts b/frontend/src/lib/components/graph/groupDetectionUtils.ts index e2c81c4d98..8dc6e3d8c2 100644 --- a/frontend/src/lib/components/graph/groupDetectionUtils.ts +++ b/frontend/src/lib/components/graph/groupDetectionUtils.ts @@ -1,7 +1,127 @@ +import { topologicalSort } from './graphBuilder.svelte' + +/** Node IDs synthesized by graphBuilder that are not real FlowModules */ +export const VIRTUAL_NODE_IDS = new Set(['Input', 'Result', 'Trigger']) + type FlowNode = { id: string; parentIds?: string[] } /** - * Use a simple algorithm to complete a group and split it into connected components + * Compute the set of module IDs that belong to a group defined by start_id and end_id. + * Uses the flattened module list (from getAllModules) and slices between start and end. + * Used for collapsed group icons, step count, and moduleToCollapsedGroup mapping. + */ +export function computeGroupModuleIds( + startId: string, + endId: string, + allModules: { id: string }[] +): string[] { + if (startId === endId) { + return allModules.some((m) => m.id === startId) ? [startId] : [] + } + + const startIdx = allModules.findIndex((m) => m.id === startId) + const endIdx = allModules.findIndex((m) => m.id === endId) + + if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { + if (startIdx > endIdx) { + console.warn( + `computeGroupModuleIds: inverted range for group ${startId}→${endId} (${startIdx} > ${endIdx})` + ) + } + return [] + } + + return allModules.slice(startIdx, endIdx + 1).map((m) => m.id) +} + +/** + * Check whether a set of selected node IDs can form a valid group. + * Normalizes marker IDs (branch/forloop) to parent module IDs, + * then uses topologicalSort to derive start and end boundaries. + */ +export function canFormValidGroup( + selectedIds: string[], + flowNodes: FlowNode[], + excludeIds?: Set +): { valid: true; startId: string; endId: string } | { valid: false } { + if (selectedIds.length === 0) return { valid: false } + + // Normalize marker IDs to parent module IDs. + // -start (forloop head) → parent ID. -end/-branch-* → skip if parent covered, else reject. + const rawSet = new Set(selectedIds) + const normalizedIds: string[] = [] + + for (const id of selectedIds) { + const parentId = id.replace(/-(end|start|branch-.*)$/, '') + if (parentId === id) { + normalizedIds.push(id) + continue + } + if (id.endsWith('-start')) { + normalizedIds.push(parentId) + continue + } + // -end or -branch-*: parent must be covered (directly or via -start) + if (!rawSet.has(parentId) && !rawSet.has(`${parentId}-start`)) { + return { valid: false } + } + } + + if (normalizedIds.length === 0) return { valid: false } + const normalizedSet = new Set(normalizedIds) + + // Topo sort full graph, filter to normalized selection. + // Include raw matches plus all markers (-start, -end, -branch-*) whose parent is selected. + const sorted = topologicalSort(flowNodes) + const selectedSorted = sorted.filter((n) => { + if (normalizedSet.has(n.id)) return true + const parentId = n.id.replace(/-(end|start|branch-.*)$/, '') + return parentId !== n.id && normalizedSet.has(parentId) + }) + + if (selectedSorted.length === 0) return { valid: false } + + // Reject virtual or excluded nodes + if (selectedSorted.some((n) => VIRTUAL_NODE_IDS.has(n.id) || excludeIds?.has(n.id))) { + return { valid: false } + } + + // Topo order is bottom-first: first = bottom (end), last = top (start). + // Use raw IDs for BFS traversal, normalize for the returned group boundaries. + const rawStartId = selectedSorted[selectedSorted.length - 1].id + const rawEndId = selectedSorted[0].id + const startId = rawStartId.replace(/-(end|start|branch-.*)$/, '') + const endId = rawEndId.replace(/-(end|start|branch-.*)$/, '') + + // Verify all selected nodes lie between start and end in the DAG. + // BFS backward from rawEndId to rawStartId to collect reachable nodes. + // Normalize collected IDs so container markers map to their parent module. + const between = new Set() + const queue = [rawEndId] + const visited = new Set() + const parentMap = new Map(flowNodes.map((n) => [n.id, n.parentIds ?? []])) + while (queue.length > 0) { + const cur = queue.shift()! + if (visited.has(cur)) continue + visited.add(cur) + const normalized = cur.replace(/-(end|start|branch-.*)$/, '') + between.add(cur) + between.add(normalized) + if (cur === rawStartId) continue + for (const p of parentMap.get(cur) ?? []) { + queue.push(p) + } + } + if (!normalizedIds.every((id) => between.has(id))) { + return { valid: false } + } + + return { valid: true, startId, endId } +} + +/** + * Legacy utility: complete a group and split it into connected components. + * Still used by NoteEditor for FlowNote group notes (contained_node_ids). */ export function completeAndSplitGroup(groupNodes: string[], flowNodes: FlowNode[]): string[][] { if (groupNodes.length <= 1) { diff --git a/frontend/src/lib/components/graph/groupEditor.svelte.ts b/frontend/src/lib/components/graph/groupEditor.svelte.ts new file mode 100644 index 0000000000..8405390b96 --- /dev/null +++ b/frontend/src/lib/components/graph/groupEditor.svelte.ts @@ -0,0 +1,325 @@ +import type { FlowModule } from '$lib/gen' +import type { StateStore } from '$lib/utils' +import type { ExtendedOpenFlow } from '../flows/types' + +import { canFormValidGroup } from './groupDetectionUtils' +import type { NoteColor } from './noteColors' +import { DEFAULT_GROUP_NOTE_COLOR, getNextAvailableColor } from './noteColors' +import { getContext, setContext } from 'svelte' + +/** + * Type for a flow group (matches the generated type from OpenAPI). + * Members are computed dynamically from all nodes on paths between start_id and end_id. + */ +export type FlowGroup = { + summary?: string + note?: string + autocollapse?: boolean + start_id: string + end_id: string + color?: string +} + +/** Derive a stable key from a group's boundaries. Used as ephemeral ID for graph nodes, runtime state, etc. */ +export function groupKey(g: { start_id: string; end_id: string }): string { + return `${g.start_id}:${g.end_id}` +} + +/** + * Display state for flow groups inside the graph. + * Handles runtime collapse state and note height tracking. + * Similar to NoteManager — instantiated inside FlowGraphV2. + */ +export class GroupDisplayState { + #getGroups: () => FlowGroup[] + #runtimeCollapsedIds = $state>(new Set()) + #runtimeInitialized = $state(false) + #noteHeights = $state>({}) + renderCount = $state(0) + + constructor(getGroups: () => FlowGroup[]) { + this.#getGroups = getGroups + } + + /** Initialize runtime state from autocollapse. Safe to call from event handlers. */ + private ensureRuntimeInitialized(): void { + if (this.#runtimeInitialized) return + const groups = this.#getGroups() + this.#runtimeCollapsedIds = new Set( + groups.filter((g) => g.autocollapse).map((g) => groupKey(g)) + ) + this.#runtimeInitialized = true + } + + /** Check if a group is currently collapsed (runtime). Safe to call from $derived. */ + isRuntimeCollapsed(groupId: string): boolean { + if (!this.#runtimeInitialized) { + return this.#getGroups().find((g) => groupKey(g) === groupId)?.autocollapse ?? false + } + return this.#runtimeCollapsedIds.has(groupId) + } + + /** Toggle runtime collapse (Minimize2 button) */ + toggleRuntimeCollapse(groupId: string): void { + this.ensureRuntimeInitialized() + const next = new Set(this.#runtimeCollapsedIds) + if (next.has(groupId)) next.delete(groupId) + else next.add(groupId) + this.#runtimeCollapsedIds = next + this.render() + } + + /** Expand a group at runtime (CollapsedGroupNode click) */ + expandGroup(groupId: string): void { + this.ensureRuntimeInitialized() + const next = new Set(this.#runtimeCollapsedIds) + next.delete(groupId) + this.#runtimeCollapsedIds = next + this.render() + } + + /** Set note height for a group (used for layout spacing) */ + setNoteHeight(groupId: string, height: number): void { + if (this.#noteHeights[groupId] !== height) { + this.#noteHeights[groupId] = height + this.render() + } + } + + /** Get all note heights */ + getNoteHeights(): Record { + return this.#noteHeights + } + + /** Bump render counter to trigger re-layout */ + render(): void { + this.renderCount++ + } + + /** Remap runtime state when a group's boundaries (and thus its key) change */ + remapGroupKey(oldKey: string, newKey: string): void { + if (this.#runtimeCollapsedIds.has(oldKey)) { + const next = new Set(this.#runtimeCollapsedIds) + next.delete(oldKey) + next.add(newKey) + this.#runtimeCollapsedIds = next + } + if (oldKey in this.#noteHeights) { + this.#noteHeights[newKey] = this.#noteHeights[oldKey] + delete this.#noteHeights[oldKey] + } + } + + /** Get currently collapsed groups for graph builder. Safe to call from $derived. */ + getCollapsedGroups(): FlowGroup[] { + if (!this.#runtimeInitialized) { + return this.#getGroups().filter((g) => g.autocollapse) + } + return this.#getGroups().filter((g) => this.#runtimeCollapsedIds.has(groupKey(g))) + } +} + +/** + * Utility class for editing flow groups via direct flowStore mutations. + * Follows the same pattern as NoteEditor. + */ +export class GroupEditor { + private flowStore: StateStore + + constructor(flowStore: StateStore) { + this.flowStore = flowStore + } + + getGroups(): FlowGroup[] { + return this.flowStore.val.value?.groups || [] + } + + private setGroups(groups: FlowGroup[]): void { + if (this.flowStore.val.value) { + this.flowStore.val.value.groups = groups + } + } + + /** IDs that cannot be part of a group (preprocessor, failure module) */ + getExcludeIds(): Set { + const excludeIds = new Set() + const pp = this.flowStore.val.value?.preprocessor_module?.id + if (pp) excludeIds.add(pp) + const fm = this.flowStore.val.value?.failure_module?.id + if (fm) excludeIds.add(fm) + return excludeIds + } + + /** Check whether the given selection can form a valid group */ + canCreateGroup( + selectedIds: string[], + flowNodes: { id: string; parentIds?: string[] }[] + ): boolean { + const result = canFormValidGroup(selectedIds, flowNodes, this.getExcludeIds()) + if (!result.valid) return false + // Reject if a group with the same boundaries already exists + return !this.getGroups().some((g) => g.start_id === result.startId && g.end_id === result.endId) + } + + /** + * Create a new group from selected node IDs. + * Uses canFormValidGroup to determine start_id and end_id. + * Returns the generated group ID. + */ + createGroup( + moduleIds: string[], + flowNodes: { id: string; parentIds?: string[] }[] + ): string | undefined { + // Filter subflow node IDs (same logic as NoteEditor.createGroupNote) + let filteredIds = [...moduleIds] + const subflowIds: string[] = [] + for (const id of moduleIds) { + if (id.startsWith('subflow:')) { + const match = id.match(/^subflow:([^:]+)/) + if (match) { + subflowIds.push(match[1]) + } + } + } + if (subflowIds.length > 0) { + filteredIds = filteredIds.filter((id) => !subflowIds.includes(id)) + filteredIds = [...filteredIds, ...subflowIds] + } + + const result = canFormValidGroup(filteredIds, flowNodes, this.getExcludeIds()) + if (!result.valid) return undefined + + const groups = this.getGroups() + + // Reject duplicate: a group with the same boundaries already exists + if (groups.some((g) => g.start_id === result.startId && g.end_id === result.endId)) { + return undefined + } + const usedColors = new Set() + for (const group of groups) { + if (group.color) { + usedColors.add(group.color as NoteColor) + } + } + const color = usedColors.size > 0 ? getNextAvailableColor(usedColors) : DEFAULT_GROUP_NOTE_COLOR + + const newGroup: FlowGroup = { + start_id: result.startId, + end_id: result.endId, + color + } + this.setGroups([...groups, newGroup]) + return groupKey(newGroup) + } + + deleteGroup(groupId: string): void { + const groups = this.getGroups() + this.setGroups(groups.filter((g) => groupKey(g) !== groupId)) + } + + updateColor(groupId: string, color: NoteColor): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, color } : g))) + } + + updateSummary(groupId: string, summary: string): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, summary } : g))) + } + + updateNote(groupId: string, note: string | undefined): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, note } : g))) + } + + /** Add a note to a group (sets note to empty string to trigger the placeholder UI) */ + addNote(groupId: string): void { + this.updateNote(groupId, '') + } + + /** Remove a note from a group */ + removeNote(groupId: string): void { + this.updateNote(groupId, undefined) + } + + updateAutocollapse(groupId: string, autocollapse: boolean): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, autocollapse } : g))) + } +} + +export type GroupEditorContext = { + groupEditor: GroupEditor + canCreateGroup: StateStore +} + +const CONTEXT_KEY = 'GroupEditorContext' + +export function setGroupEditorContext( + groupEditor: GroupEditor, + canCreateGroup: StateStore +): void { + setContext(CONTEXT_KEY, { groupEditor, canCreateGroup }) +} + +export function getGroupEditorContext(): GroupEditorContext | undefined { + return getContext(CONTEXT_KEY) +} + +/** Height of the group header bar */ +export const GROUP_HEADER_HEIGHT = 22 + +/** Extra margin between the header and the first node */ +export const GROUP_TOP_MARGIN = 30 + +export type GraphGroup = FlowGroup & { + id: string + moduleIds: string[] +} + +export type ContainerInnerArray = { + get: () => FlowModule[] + set: (v: any) => void + label?: string +} + +/** Get inner arrays from a container FlowModule with direct get/set accessors. */ +export function getContainerInnerArrays(mod: FlowModule): ContainerInnerArray[] { + const val = mod.value as any + if (val.type === 'forloopflow' || val.type === 'whileloopflow') { + return [ + { + get: () => val.modules, + set: (v) => { + val.modules = v + } + } + ] + } else if (val.type === 'branchone') { + return [ + { + get: () => val.default, + set: (v) => { + val.default = v + }, + label: 'Default' + }, + ...val.branches.map((b: any, i: number) => ({ + get: () => b.modules, + set: (v: any) => { + b.modules = v + }, + label: b.summary || `Branch ${i + 1}` + })) + ] + } else if (val.type === 'branchall') { + return val.branches.map((b: any, i: number) => ({ + get: () => b.modules, + set: (v: any) => { + b.modules = v + }, + label: b.summary || `Branch ${i + 1}` + })) + } + return [] +} diff --git a/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts b/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts new file mode 100644 index 0000000000..6965baf531 --- /dev/null +++ b/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts @@ -0,0 +1,181 @@ +import { untrack } from 'svelte' +import type { FlowModule } from '$lib/gen' +import { type FlowGroup, type GraphGroup, groupKey } from './groupEditor.svelte' +import type { StateStore } from '$lib/utils' +import { getAllModules } from '../flows/flowExplorer' +import { computeGroupModuleIds } from './groupDetectionUtils' +import { stateSnapshot } from '$lib/svelte5Utils.svelte' +import { + buildStructureTree, + deriveGroupsFromStructure, + applyStructureToModules, + removeEmptyGroups, + findDuplicateGroups, + removeDuplicateGroups, + flattenStructureIds, + type FlowStructureNode +} from './flowStructure' + +export type ExtendedOpenFlow = { + value: { + modules: FlowModule[] + groups?: FlowGroup[] + [key: string]: any + } + [key: string]: any +} + +/** + * Reactive read-only view of the flow structure tree. + * The tree is always derived from flowStore (single source of truth). + * Mutations go through prepareMutation: snapshot → mutate → clean empty groups → commit. + */ +export class GroupedModulesProxy { + #items = $state([]) + #error = $state(undefined) + #flowStore: StateStore + + constructor(flowStore: StateStore) { + this.#flowStore = flowStore + this.rebuild() + + // Rebuild tree whenever store changes (undo/load/mutation) + $effect(() => { + void flowStore.val.value.modules + void flowStore.val.value.groups + untrack(() => this.rebuild()) + }) + } + + /** Reactive access to the structure tree (read-only view) */ + get items(): FlowStructureNode[] { + return this.#items + } + + /** Reactive access to build errors */ + get error(): unknown { + return this.#error + } + + /** + * Prepare a structural mutation without writing to the store yet. + * Returns the list of groups that became empty (already removed from the snapshot) + * and a `commit` function that writes the result to the store. + * + * If no groups were emptied, the caller can commit immediately. + * If groups were emptied, the caller should show a confirmation modal + * and call commit() only on user confirmation. + */ + prepareMutation( + mutate: (tree: FlowStructureNode[]) => void, + opts?: { + extraModules?: FlowModule[] + displayState?: import('./groupEditor.svelte').GroupDisplayState + } + ): { + emptiedGroups: FlowGroup[] + duplicateGroups: FlowGroup[] + commit: (commitOpts?: { removeDuplicates?: boolean }) => void + } { + const snapshot = $state.snapshot(this.#items) as FlowStructureNode[] + mutate(snapshot) + + // Clean up empty groups and collect which ones were removed + const emptiedGroups = removeEmptyGroups(snapshot) + // Detect groups that became duplicates after the mutation + const duplicateGroups = findDuplicateGroups(snapshot) + + const commit = (commitOpts?: { removeDuplicates?: boolean }) => { + if (commitOpts?.removeDuplicates && duplicateGroups.length > 0) { + removeDuplicateGroups(snapshot) + } + + // Remap runtime state for groups whose boundaries shifted + if (opts?.displayState) { + this.#remapChangedGroupKeys(snapshot, opts.displayState) + } + + // Build moduleMap lazily at commit time so it reflects the latest store state + const moduleMap = new Map() + for (const m of getAllModules(this.#flowStore.val.value.modules)) { + moduleMap.set(m.id, m) + } + if (opts?.extraModules) { + for (const m of opts.extraModules) { + moduleMap.set(m.id, m) + } + } + this.#flowStore.val.value.modules = applyStructureToModules(snapshot, moduleMap) + this.#flowStore.val.value.groups = deriveGroupsFromStructure(snapshot) + } + + return { emptiedGroups, duplicateGroups, commit } + } + + /** + * Convenience: prepare + auto-commit. Only use for mutations that cannot + * empty groups (e.g. inserts). Throws if groups are unexpectedly emptied. + * For mutations that may empty groups, use prepareMutation() directly. + */ + applyTreeMutation( + mutate: (tree: FlowStructureNode[]) => void, + opts?: { + extraModules?: FlowModule[] + displayState?: import('./groupEditor.svelte').GroupDisplayState + } + ): void { + const { emptiedGroups, duplicateGroups, commit } = this.prepareMutation(mutate, opts) + if (emptiedGroups.length > 0) { + console.error('applyTreeMutation: unexpected empty groups', emptiedGroups) + } + if (duplicateGroups.length > 0) { + console.error('applyTreeMutation: unexpected duplicate groups', duplicateGroups) + } + commit() + } + + /** Remap runtime state for group nodes whose boundaries shifted after a mutation. */ + #remapChangedGroupKeys( + snapshot: FlowStructureNode[], + displayState: import('./groupEditor.svelte').GroupDisplayState + ): void { + const walk = (nodes: FlowStructureNode[]) => { + for (const node of nodes) { + if (node.kind === 'group') { + const oldKey = node.id + const flatIds = flattenStructureIds(node.branches[0].children) + const newKey = flatIds.length > 0 ? `${flatIds[0]}:${flatIds[flatIds.length - 1]}` : null + if (newKey && oldKey !== newKey) { + displayState.remapGroupKey(oldKey, newKey) + } + walk(node.branches[0].children) + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(snapshot) + } + + /** Rebuild from flowStore */ + private rebuild(): void { + const modules = stateSnapshot(this.#flowStore.val.value.modules) as FlowModule[] + const allGroups = this.#flowStore.val.value.groups ?? [] + const allModules = getAllModules(modules) + const graphGroups: GraphGroup[] = allGroups.map((g) => ({ + ...g, + id: groupKey(g), + moduleIds: computeGroupModuleIds(g.start_id, g.end_id, allModules) + })) + try { + this.#items = buildStructureTree(modules, graphGroups) + this.#error = undefined + } catch (e) { + // Intentionally preserve last-known-good #items so the graph + // can still render while the error is surfaced to the user. + this.#error = e + } + } +} diff --git a/frontend/src/lib/components/graph/moveManager.svelte.ts b/frontend/src/lib/components/graph/moveManager.svelte.ts index 605c4b849a..1539cc0802 100644 --- a/frontend/src/lib/components/graph/moveManager.svelte.ts +++ b/frontend/src/lib/components/graph/moveManager.svelte.ts @@ -204,9 +204,6 @@ export class MoveManager { for (const [edgeId, zone] of this.#registeredDropZones) { if (zone.disableMoveIds.includes(draggedId)) continue - // Skip edges adjacent to the dragged node (no-op move) - if (zone.sourceId === draggedId || zone.targetId === draggedId) continue - const dx = Math.abs(flowPos.x - zone.centerX) const dy = Math.abs(flowPos.y - zone.centerY) diff --git a/frontend/src/lib/components/graph/nodeExtraSpace.ts b/frontend/src/lib/components/graph/nodeExtraSpace.ts new file mode 100644 index 0000000000..f51309a60b --- /dev/null +++ b/frontend/src/lib/components/graph/nodeExtraSpace.ts @@ -0,0 +1,153 @@ +import type { FlowNote } from '../../gen' +import type { AssetWithAltAccessType } from '../assets/lib' +import { + assetDisplaysAsInputInFlowGraph, + assetDisplaysAsOutputInFlowGraph, + NODE_WITH_READ_ASSET_Y_OFFSET, + NODE_WITH_WRITE_ASSET_Y_OFFSET +} from './renderers/nodes/AssetNode.svelte' +import { + AI_TOOL_BASE_OFFSET, + AI_TOOL_ROW_OFFSET, + BELOW_ADDITIONAL_OFFSET +} from './renderers/nodes/AIToolNode.svelte' +import { topologicalSort } from './graphBuilder.svelte' +import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte' +import type { GroupDisplayState } from './groupEditor.svelte' +import type { GraphModuleState } from '.' + +type NodeDep = { + id: string + parentIds?: string[] + data?: { assets?: AssetWithAltAccessType[]; module?: any } +} + +type ExtraSpace = { top: number; bottom: number; left: number; right: number } + +const MAX_TOOLS_PER_ROW = 2 + +/** + * Pre-compute extra top/bottom space each node needs for decorations + * (assets, AI tools, group headers, group notes). + */ +export function computeNodeExtraSpace( + graphNodes: NodeDep[], + opts: { + showAssets: boolean + showNotes: boolean + notes: FlowNote[] | undefined + noteTextHeights: Record + groupDisplayState: GroupDisplayState + insertable: boolean + flowModuleStates: Record | undefined + } +): Map | undefined { + const extraSpace = new Map() + + // 1. Assets + if (opts.showAssets) { + for (const node of graphNodes) { + const assets = node.data?.assets ?? [] + if (!assets.length) continue + const hasRead = assets.some(assetDisplaysAsInputInFlowGraph) + const hasWrite = assets.some(assetDisplaysAsOutputInFlowGraph) + if (hasRead || hasWrite) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + top: prev.top + (hasRead ? NODE_WITH_READ_ASSET_Y_OFFSET : 0), + bottom: prev.bottom + (hasWrite ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0) + }) + } + } + } + + // 2. AI tools + for (const node of graphNodes) { + const mod = node.data?.module + if (!mod || mod.value?.type !== 'aiagent') continue + + const agentActions = !opts.insertable && opts.flowModuleStates?.[node.id]?.agent_actions + + if (agentActions) { + // Execution mode: tools below + const totalRows = Math.ceil(agentActions.length / MAX_TOOLS_PER_ROW) + const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + BELOW_ADDITIONAL_OFFSET + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { ...prev, bottom: prev.bottom + space }) + } else { + // Edit mode: tools above + const tools = mod.value.tools ?? [] + const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (opts.insertable ? 1 : 0) + const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { ...prev, top: prev.top + space }) + } + } + + // Topological sort (reversed: top-of-graph first) — shared by group notes and group headers + const sortedNodes = topologicalSort(graphNodes).reverse() + + // 3. Group notes (text above topmost node in each group note) + if (opts.showNotes) { + const groupNotes = (opts.notes ?? []).filter((n) => n.type === 'group') + if (groupNotes.length > 0) { + for (const groupNote of groupNotes) { + if (!groupNote.contained_node_ids?.length) continue + const topmostNodeId = sortedNodes.find((node) => + groupNote.contained_node_ids?.includes(node.id) + )?.id + if (topmostNodeId) { + const textHeight = opts.noteTextHeights[groupNote.id] || 60 + const spacing = textHeight + 16 // padding + const prev = extraSpace.get(topmostNodeId) ?? { + top: 0, + bottom: 0, + left: 0, + right: 0 + } + extraSpace.set(topmostNodeId, { + ...prev, + top: Math.max(prev.top, spacing + prev.top) + }) + } + } + } + } + + // 4. Collapsed group nodes are taller than regular nodes (header + module icons) + for (const node of graphNodes) { + if (node.id.startsWith('collapsed-group:')) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + bottom: prev.bottom + GROUP_HEADER_HEIGHT + }) + } + } + + // 5. Group nodes (expanded heads and collapsed) with notes need extra height + if (opts.showNotes) { + const noteHeights = opts.groupDisplayState.getNoteHeights() + for (const node of graphNodes) { + let groupId: string | undefined + if (node.id.startsWith('group:') && !node.id.endsWith('-end')) { + groupId = node.id.slice('group:'.length) + } else if (node.id.startsWith('collapsed-group:')) { + groupId = node.id.slice('collapsed-group:'.length) + } + if (groupId) { + const noteHeight = noteHeights[groupId] + if (noteHeight && noteHeight > 0) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + bottom: prev.bottom + noteHeight + }) + } + } + } + } + + return extraSpace.size > 0 ? extraSpace : undefined +} diff --git a/frontend/src/lib/components/graph/noteColors.ts b/frontend/src/lib/components/graph/noteColors.ts index f9024adf7e..2a82ed1f40 100644 --- a/frontend/src/lib/components/graph/noteColors.ts +++ b/frontend/src/lib/components/graph/noteColors.ts @@ -14,6 +14,7 @@ export enum NoteColor { export interface NoteColorConfig { background: string + backgroundLight: string outline: string outlineHover: string text: string @@ -24,70 +25,80 @@ export interface NoteColorConfig { export const NOTE_COLORS: Record = { [NoteColor.YELLOW]: { background: 'bg-yellow-200 dark:bg-yellow-900', - outline: 'outline-yellow-300 dark:outline-yellow-600', + backgroundLight: 'bg-yellow-400/5 dark:bg-yellow-600/5', + outline: 'outline-yellow-200 dark:outline-yellow-900', outlineHover: 'outline-yellow-300/60 dark:outline-yellow-600/60', text: 'text-yellow-900 dark:text-yellow-100', hover: 'hover:bg-yellow-200 dark:hover:bg-yellow-800' }, [NoteColor.BLUE]: { background: 'bg-blue-100 dark:bg-blue-950', - outline: 'outline-blue-300 dark:outline-blue-600', + backgroundLight: 'bg-blue-400/5 dark:bg-blue-600/5', + outline: 'outline-blue-100 dark:outline-blue-950', outlineHover: 'outline-blue-300/60 dark:outline-blue-600/60', text: 'text-blue-900 dark:text-blue-100', hover: 'hover:bg-blue-200 dark:hover:bg-blue-800' }, [NoteColor.GREEN]: { background: 'bg-green-200 dark:bg-green-900', - outline: 'outline-green-300 dark:outline-green-600', + backgroundLight: 'bg-green-400/5 dark:bg-green-600/5', + outline: 'outline-green-200 dark:outline-green-900', outlineHover: 'outline-green-300/60 dark:outline-green-600/60', text: 'text-green-900 dark:text-green-100', hover: 'hover:bg-green-200 dark:hover:bg-green-800' }, [NoteColor.PURPLE]: { background: 'bg-purple-200 dark:bg-purple-900', - outline: 'outline-purple-300 dark:outline-purple-600', + backgroundLight: 'bg-purple-400/5 dark:bg-purple-600/5', + outline: 'outline-purple-200 dark:outline-purple-900', outlineHover: 'outline-purple-300/60 dark:outline-purple-600/60', text: 'text-purple-900 dark:text-purple-100', hover: 'hover:bg-purple-200 dark:hover:bg-purple-800' }, [NoteColor.PINK]: { background: 'bg-pink-200 dark:bg-pink-900', - outline: 'outline-pink-300 dark:outline-pink-600', + backgroundLight: 'bg-pink-400/5 dark:bg-pink-600/5', + outline: 'outline-pink-200 dark:outline-pink-900', outlineHover: 'outline-pink-300/60 dark:outline-pink-600/60', text: 'text-pink-900 dark:text-pink-100', hover: 'hover:bg-pink-200 dark:hover:bg-pink-800' }, [NoteColor.ORANGE]: { background: 'bg-orange-200 dark:bg-orange-900', - outline: 'outline-orange-300 dark:outline-orange-600', + backgroundLight: 'bg-orange-400/5 dark:bg-orange-600/5', + outline: 'outline-orange-200 dark:outline-orange-900', outlineHover: 'outline-orange-300/60 dark:outline-orange-600/60', text: 'text-orange-900 dark:text-orange-100', hover: 'hover:bg-orange-200 dark:hover:bg-orange-800' }, [NoteColor.RED]: { background: 'bg-red-200 dark:bg-red-900', - outline: 'outline-red-300 dark:outline-red-600', + backgroundLight: 'bg-red-400/5 dark:bg-red-600/5', + outline: 'outline-red-200 dark:outline-red-900', outlineHover: 'outline-red-300/60 dark:outline-red-600/60', text: 'text-red-900 dark:text-red-100', hover: 'hover:bg-red-200 dark:hover:bg-red-800' }, [NoteColor.CYAN]: { background: 'bg-cyan-200 dark:bg-cyan-900', - outline: 'outline-cyan-300 dark:outline-cyan-600', + backgroundLight: 'bg-cyan-400/5 dark:bg-cyan-600/5', + outline: 'outline-cyan-200 dark:outline-cyan-900', outlineHover: 'outline-cyan-300/60 dark:outline-cyan-600/60', text: 'text-cyan-900 dark:text-cyan-100', hover: 'hover:bg-cyan-200 dark:hover:bg-cyan-800' }, [NoteColor.LIME]: { background: 'bg-lime-200 dark:bg-lime-900', - outline: 'outline-lime-300 dark:outline-lime-600', + backgroundLight: 'bg-lime-400/5 dark:bg-lime-600/5', + outline: 'outline-lime-200 dark:outline-lime-900', outlineHover: 'outline-lime-300/60 dark:outline-lime-600/60', text: 'text-lime-900 dark:text-lime-100', hover: 'hover:bg-lime-200 dark:hover:bg-lime-800' }, [NoteColor.GRAY]: { background: 'bg-gray-200 dark:bg-gray-800', - outline: 'outline-gray-300 dark:outline-gray-600', + backgroundLight: 'bg-gray-400/5 dark:bg-gray-600/5', + outline: 'outline-gray-200 dark:outline-gray-800', outlineHover: 'outline-gray-300/60 dark:outline-gray-600/60', text: 'text-gray-900 dark:text-gray-100', hover: 'hover:bg-gray-200 dark:hover:bg-gray-700' diff --git a/frontend/src/lib/components/graph/noteUtils.svelte.ts b/frontend/src/lib/components/graph/noteUtils.svelte.ts index 3350e07ce8..bd7fc2ce05 100644 --- a/frontend/src/lib/components/graph/noteUtils.svelte.ts +++ b/frontend/src/lib/components/graph/noteUtils.svelte.ts @@ -19,20 +19,6 @@ export type NodeDep = { export type NoteComputeResult = { noteNodes: (Node & NodeLayout)[] - newNodePositions: Record -} - -export type AIToolSpacingInfo = { - toolNodes: (Node & NodeLayout)[] - toolEdges: any[] - newNodePositions: Record -} - -export interface GroupNoteBounds { - x: number - y: number - width: number - height: number } let computeNoteNodesCache: @@ -283,14 +269,9 @@ export function computeNoteNodes( const allNoteNodes: (Node & NodeLayout)[] = [] - // Build a map of Y positions that need extra spacing for group notes - const yPosMap: Record = {} // Y position -> spacing needed - - // Group notes that need spacing + // Find topmost node per group note for layout calculation const groupNotes = notes.filter((n) => n.type === 'group') - const topMostNodesMap: Record = {} - const sortedNodes = topologicalSort(nodes).reverse() for (const groupNote of groupNotes) { @@ -298,47 +279,12 @@ export function computeNoteNodes( const topmostNodeId = sortedNodes.find((node) => groupNote.contained_node_ids?.includes(node.id) )?.id - const topmostNode = nodes.find((node) => node.id === topmostNodeId) - if (topmostNode) { - const textHeight = noteTextHeights[groupNote.id] || 60 - const spacing = textHeight + 16 // padding - // Mark this Y position as needing spacing - yPosMap[topmostNode.position.y] = Math.max(yPosMap[topmostNode.position.y] || 0, spacing) - topMostNodesMap[groupNote.id] = topmostNode.id + if (topmostNodeId) { + topMostNodesMap[groupNote.id] = topmostNodeId } } } - // Calculate new positions for nodes (offset by group notes) - const sortedNewNodes = nodes - .map((n) => ({ position: { ...n.position }, id: n.id })) - .sort((a, b) => a.position.y - b.position.y) - - let currentYOffset = 0 - let prevYPos = NaN - - for (const node of sortedNewNodes) { - if (node.position.y !== prevYPos) { - // Add spacing for group notes at this Y level - if (yPosMap[node.position.y]) { - currentYOffset += yPosMap[node.position.y] - } - prevYPos = node.position.y - } - node.position.y += currentYOffset - } - - // Create note nodes AFTER calculating adjusted node positions - // For group notes, we need to use the adjusted node positions - const adjustedNodes = sortedNewNodes.map((n) => { - const origNode = nodes.find((orig) => orig.id === n.id) - return { - ...n, - data: origNode?.data, - type: origNode?.type - } - }) - // Calculate all z-indexes at once using hierarchy information const noteZIndexes = calculateAllNoteZIndexes(notes, nodes) @@ -346,11 +292,11 @@ export function computeNoteNodes( const isGroupNote = note.type === 'group' const zIndex = noteZIndexes[note.id] - // Calculate position and size using adjusted node positions for group notes + // Calculate position and size using node positions for group notes const { position, size } = isGroupNote ? calculateGroupNoteLayout( note, - adjustedNodes, + nodes, noteTextHeights[note.id] || 60, topMostNodesMap[note.id] ) @@ -375,13 +321,8 @@ export function computeNoteNodes( allNoteNodes.push(noteNode) } - const newNodePositions: Record = Object.fromEntries( - sortedNewNodes.map((n) => [n.id, n.position]) - ) - const result: NoteComputeResult = { - noteNodes: allNoteNodes, - newNodePositions + noteNodes: allNoteNodes } // Cache the result diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 8f1ca0017f..5fbf084374 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -12,11 +12,14 @@ import type { GraphModuleState } from '../../model' import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte' import { getGraphContext } from '../../graphContext' + import { GROUP_TOP_PADDING } from '$lib/components/graph/compoundLayout' const { useDataflow, showAssets, moveManager } = getGraphContext() let { id, + source, + target, sourceX, sourceY, sourcePosition, @@ -45,6 +48,13 @@ } } = $props() + // Derive group boundary from source/target node IDs + let groupBoundary: 'top' | 'bottom' | undefined = $derived.by(() => { + if (source.startsWith('group:') && !source.endsWith('-end')) return 'top' + if (target.startsWith('group:') && target.endsWith('-end')) return 'bottom' + return undefined + }) + let [edgePath] = $derived( getBezierPath({ sourceX, @@ -75,9 +85,15 @@ ) let centerY = $derived( - sourceY + - 32 + - (data.shouldOffsetInsertBtnDueToAssetNode && $showAssets ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0) + groupBoundary === 'bottom' + ? targetY + : groupBoundary === 'top' + ? sourceY + GROUP_TOP_PADDING / 2 + : sourceY + + 32 + + (data.shouldOffsetInsertBtnDueToAssetNode && $showAssets + ? NODE_WITH_WRITE_ASSET_Y_OFFSET + : 0) ) let isDragging = $derived(!!moveManager?.dragging) @@ -87,13 +103,13 @@ data?.insertable && draggedId !== undefined && !data.disableMoveIds?.includes(draggedId) && - data.sourceId !== draggedId && - data.targetId !== draggedId + source !== draggedId && + target !== draggedId ) - let isNearestDrop = $derived(isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id) - let isAdjacentToDragged = $derived( - isDragging && (data?.sourceId === draggedId || data?.targetId === draggedId) + let isNearestDrop = $derived( + isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id ? true : false ) + let isAdjacentToDragged = $derived(isDragging && (source === draggedId || target === draggedId)) // Register this edge's drop zone position with the drag manager so proximity // detection uses the actual xyflow-computed position rather than re-deriving it. @@ -161,7 +177,7 @@ {@render dropTargetIndicator(isNearestDrop)}
    - {:else if data?.insertable && !$useDataflow && !moveManager?.movingModuleId && !isDragging} + {:else if data?.insertable && !groupBoundary && !$useDataflow && !moveManager?.movingModuleId && !isDragging}
    - {#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some((id) => data.disableMoveIds?.includes(id))} + {#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some( (id) => data.disableMoveIds?.includes(id) )} - {/if} -
    +
    + {/if} {/snippet} diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte index 2ffa6a8d49..ccc5643981 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte @@ -53,7 +53,7 @@
    +
    + {#if hubSyncStatus === 'success'} +
    + + {hubSyncMessage} + +
    + {:else if hubSyncStatus === 'error'} +
    + + {hubSyncMessage} + +
    + {/if} + {/if} + + +{/if} diff --git a/frontend/src/lib/components/settings/AIPromptsModal.svelte b/frontend/src/lib/components/settings/AIPromptsModal.svelte index a0ea762c8d..b9c126d19c 100644 --- a/frontend/src/lib/components/settings/AIPromptsModal.svelte +++ b/frontend/src/lib/components/settings/AIPromptsModal.svelte @@ -13,7 +13,7 @@ onSave?: () => void onReset: () => void hasChanges: boolean - isWorkspaceSettings?: boolean + scope?: 'user' | 'workspace' | 'instance' } let { @@ -22,7 +22,7 @@ onSave, onReset, hasChanges, - isWorkspaceSettings = false + scope = 'user' }: Props = $props() const placeholders: Record = { @@ -63,9 +63,12 @@
    - {#if isWorkspaceSettings} + {#if scope === 'workspace'} Customize the system prompts for each AI mode. These prompts apply to all workspace members. + {:else if scope === 'instance'} + Customize the system prompts for each AI mode. These prompts apply to workspaces using + instance AI defaults. {:else} Customize the system prompts for each AI mode. These prompts are stored locally in your browser and apply in addition to workspace-level prompts. diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index 2ed3432a3c..dd3cc6e0d0 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -1,5 +1,12 @@ - +
    - -
    - {#each Object.entries(AI_PROVIDERS) as [provider, details]} -
    -
    - { - if (e.detail) { - aiProviders = { - ...aiProviders, - [provider]: { - resource_path: '', - models: - availableAiModels[provider].length > 0 - ? [availableAiModels[provider][0]] - : [] - } - } - - if (availableAiModels[provider].length > 0 && !defaultModel) { - defaultModel = availableAiModels[provider][0] - } - } else { - aiProviders = Object.fromEntries( - Object.entries(aiProviders).filter(([key]) => key !== provider) - ) - if (defaultModel) { - const currentDefaultModel = Object.values(aiProviders).find( - (p) => defaultModel && p.models.includes(defaultModel) - ) - if (!currentDefaultModel) { - defaultModel = undefined - } - } - if (codeCompletionModel) { - const currentCodeCompletionModel = Object.values(aiProviders).find( - (p) => codeCompletionModel && p.models.includes(codeCompletionModel) - ) - if (!currentCodeCompletionModel) { - codeCompletionModel = undefined - } - } - } - }} - /> - {#if provider === 'anthropic'} - - Recommended - - Anthropic models handle tool calls better than other providers, which makes them a - better choice for AI chat. - - - {/if} -
    - - {#if aiProviders[provider]} -
    -
    - {/if} -
    + + {#key Object.keys(aiProviders).length} + + +
    {/if}
    - + + + + +
    + + {#if promptCount > 0} + ({promptCount} configured) + {/if} + {#if hasPromptsChanges} + Unsaved changes + {/if} +
    +
    + {/if}
    - onDiscard?.()} - saveLabel="Save AI settings" - disabled={!Object.values(aiProviders).every((p) => p.resource_path) || - (codeCompletionModel != undefined && codeCompletionModel.length === 0) || - (Object.keys(aiProviders).length > 0 && !defaultModel)} -/> +{#if showWorkspaceOverrideEditor} + +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte index 7f4b4e0f67..e02facb38e 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte @@ -11,7 +11,8 @@ VariableService, WorkspaceService, type AIProvider, - type CompletedJob + type CompletedJob, + type GetCopilotInfoResponse } from '$lib/gen' import { validateUsername } from '$lib/utils' import { logoutWithRedirect } from '$lib/logoutKit' @@ -52,6 +53,10 @@ let aiKey = $state('') let codeCompletionEnabled = $state(true) let checking = $state(false) + let createLoading = $state(false) + let aiSetupLoading = $state(false) + let creationStep = $state<'details' | 'ai'>('details') + let createdWorkspaceId: string | undefined = $state(undefined) let workspaceColor: string | undefined = $state(undefined) let colorEnabled = $state(false) @@ -85,6 +90,64 @@ let errorMsgs: string[] = $state([]) let failedSyncJobs: string[] = $state([]) + function getErrorMessage(error: any): string { + return ( + error?.body?.error?.message || + error?.body?.message || + (typeof error?.body === 'string' ? error.body : null) || + error?.message || + 'Unknown error' + ) + } + + function hasEffectiveAi(copilotInfo: GetCopilotInfoResponse): boolean { + return Object.keys(copilotInfo.providers ?? {}).length > 0 + } + + async function finishWorkspaceSetup(workspaceId: string): Promise { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + switchWorkspace(workspaceId) + goto(rd ?? '/') + } + + async function getWorkspaceUsername(workspaceId: string): Promise { + if (!automateUsernameCreation) { + return username + } + + const user = await UserService.whoami({ + workspace: workspaceId + }) + return user.username + } + + async function maybeShowAiSetupStep(workspaceId: string): Promise { + try { + const copilotInfo = await WorkspaceService.getCopilotInfo({ + workspace: workspaceId + }) + + if (hasEffectiveAi(copilotInfo)) { + await finishWorkspaceSetup(workspaceId) + return + } + } catch (error) { + console.error('Failed to check effective AI configuration for new workspace', error) + sendUserToast( + 'Workspace created, but Windmill AI availability could not be verified. You can configure it later in Workspace settings.', + true + ) + await finishWorkspaceSetup(workspaceId) + return + } + + createdWorkspaceId = workspaceId + creationStep = 'ai' + aiKey = '' + codeCompletionEnabled = true + selected = 'openai' + } + async function fetchFailedSyncJobs(jobs: string[]): Promise { let ret: CompletedJob[] = [] for (const job of jobs) { @@ -188,20 +251,22 @@ forkCreationLoading = false sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`) + await finishWorkspaceSetup(prefixed_id) } else { sendUserToast('No workspace selected, cannot fork non-existent workspace', true) } } else { - await createWorkspace() + createLoading = true + try { + const workspaceId = await createWorkspace() + await maybeShowAiSetupStep(workspaceId) + } finally { + createLoading = false + } } - - usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) - switchWorkspace(isFork ? prefixed_id : id) - - goto(rd ?? '/') } - async function createWorkspace(): Promise { + async function createWorkspace(): Promise { await WorkspaceService.createWorkspace({ requestBody: { id, @@ -216,17 +281,23 @@ requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: autoAdd } }) } - if (aiKey != '') { - let actualUsername = username - if (automateUsernameCreation) { - const user = await UserService.whoami({ - workspace: id - }) - actualUsername = user.username - } - let path = `u/${actualUsername}/${selected}_windmill_codegen` + + sendUserToast(`Created workspace id: ${id}`) + return id + } + + async function saveWorkspaceAiSetup(): Promise { + if (!createdWorkspaceId || !aiKey) { + return + } + + aiSetupLoading = true + try { + const actualUsername = await getWorkspaceUsername(createdWorkspaceId) + const path = `u/${actualUsername}/${selected}_windmill_codegen` + await VariableService.createVariable({ - workspace: id, + workspace: createdWorkspaceId, requestBody: { path, value: aiKey, @@ -235,7 +306,7 @@ } }) await ResourceService.createResource({ - workspace: id, + workspace: createdWorkspaceId, requestBody: { path, value: { @@ -245,40 +316,46 @@ } }) await WorkspaceService.editCopilotConfig({ - workspace: id, - requestBody: aiKey - ? { - providers: { - [selected]: { - resource_path: path, - models: [AI_PROVIDERS[selected].defaultModels[0]] - } - }, - default_model: { - model: AI_PROVIDERS[selected].defaultModels[0], - provider: selected - }, - code_completion_model: codeCompletionEnabled - ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } - : undefined + workspace: createdWorkspaceId, + requestBody: { + providers: { + [selected]: { + resource_path: path, + models: [AI_PROVIDERS[selected].defaultModels[0]] } - : {} + }, + default_model: { + model: AI_PROVIDERS[selected].defaultModels[0], + provider: selected + }, + code_completion_model: codeCompletionEnabled + ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } + : undefined + } }) + + sendUserToast('Windmill AI configured') + await finishWorkspaceSetup(createdWorkspaceId) + } catch (error) { + sendUserToast(`Failed to configure Windmill AI: ${getErrorMessage(error)}`, true) + } finally { + aiSetupLoading = false } - - sendUserToast(`Created workspace id: ${id}`) - - usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) - switchWorkspace(id) - - goto(rd ?? '/') } - function handleKeyUp(event: KeyboardEvent) { + function handleCreateKeyUp(event: KeyboardEvent) { const key = event.key if (key === 'Enter') { event.preventDefault() - createWorkspace() + createOrForkWorkspace() + } + } + + function handleAiKeyUp(event: KeyboardEvent) { + const key = event.key + if (key === 'Enter' && aiKey) { + event.preventDefault() + saveWorkspaceAiSetup() } } @@ -329,6 +406,9 @@ let operatorOnly = $state(false) let autoAdd = $state(true) let selected: Exclude = $state('openai') + let modalTitle = $derived( + isFork ? 'Fork Workspace' : creationStep === 'ai' ? 'Set up Windmill AI' : 'New Workspace' + ) run(() => { id = name.toLowerCase().replace(/\s/gi, '-') }) @@ -344,7 +424,7 @@ let domain = $derived($usersWorkspaceStore?.email.split('@')[1]) - +
    {#if isFork}
    @@ -410,88 +490,184 @@ {/if} {/if} - - - - {#if !automateUsernameCreation} + {#if isFork || creationStep === 'details'} + - {/if} - {#if !isFork} -
    + + {#if !automateUsernameCreation} + + {/if} + {#if !isFork} +
    + + + {#if isCloudHosted() && isDomainAllowed == false} +
    {domain} domain not allowed for auto-invite
    + {/if} + + {#if auto_invite} +
    + + {#if isCloudHosted()} + + {/if} + + +
    + {/if} +
    + {/if} + +
    + + {#if !forkCreationLoading} + + {:else} + + {/if} +
    + {:else} +
    - (optional but recommended) + + Windmill AI powers the chat, code generation, flow creation, and code completion. Set + it up now or configure it later in Workspace settings. + + Learn more + + - + {#snippet children({ item })} @@ -517,7 +704,7 @@ type="password" autocomplete="new-password" bind:value={aiKey} - onkeyup={handleKeyUp} + onkeyup={handleAiKeyUp} /> {#if aiKey} -
    +
    {/if}
    -
    - +
    - {/if} -
    - - {#if !forkCreationLoading} + Skip for now + - {:else} - - {/if} -
    +
    + {/if}
    diff --git a/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte new file mode 100644 index 0000000000..a44981ccbd --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte @@ -0,0 +1,93 @@ + + +{#if instanceAiSummary} + +
    +

    + This workspace is currently using the instance AI defaults shown below. +

    + +
    + {#each sortedInstanceProviders as providerSummary} +
    +
    + + {getProviderLabel(providerSummary.provider)} + + Instance +
    +
    + {#each providerSummary.models as model} + {model} + {/each} +
    +
    + {/each} +
    + + {#if instanceAiSummary.default_model} +
    + Default chat model: + {instanceAiSummary.default_model.model} + + ({getProviderLabel(instanceAiSummary.default_model.provider)}) + +
    + {/if} + + {#if instanceAiSummary.code_completion_model} +
    + Code completion model: + + {instanceAiSummary.code_completion_model.model} + + + ({getProviderLabel(instanceAiSummary.code_completion_model.provider)}) + +
    + {/if} +
    +
    +{/if} + + +
    +

    + Create workspace-specific AI settings only if this workspace needs to override the active + instance defaults. +

    +
    + +
    +
    +
    diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index 66d9568c09..36f9be8faa 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -25,13 +25,20 @@ import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import InstanceAISettings from '$lib/components/instanceSettings/InstanceAISettings.svelte' const settingsSteps = [ { id: 'Core', label: 'Core' }, { id: 'Auth/OAuth/SAML', label: 'Authentication' } ] as const - const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types'] + const AI_STEP_INDEX = settingsSteps.length + + const wizardStepLabels = [ + ...settingsSteps.map((s) => s.label), + 'AI', + 'Root login & Resource Types' + ] const fullStepLabels = ['Settings', 'Root login & Resource Types'] @@ -67,6 +74,7 @@ }) let instanceSettings: InstanceSettings | undefined = $state() + let instanceAiSettings: InstanceAISettings | undefined = $state() function isSettingsStep(step: number): boolean { return step < settingsSteps.length @@ -148,6 +156,9 @@ let passwordValid = $derived(newPassword.length >= 2) let accountFormValid = $derived(emailValid && passwordValid) + // --- AI step state --- + let aiHasUnsavedChanges = $state(false) + // --- EE license key warning --- let showLicenseKeyWarning = $state(false) let pendingNextCallback: (() => void) | undefined = $state(undefined) @@ -168,9 +179,20 @@ let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[fullTab] ?? 'sso') let yamlMode = $state(false) - function handleNavigate(newTab: string) { - if (newTab === fullTab) return + function isAiStepActive(): boolean { + return ( + (mode === 'wizard' && wizardStep === AI_STEP_INDEX) || + (mode === 'full' && fullStep === 0 && fullTab === 'ai' && !yamlMode) + ) + } + + async function handleNavigate(newTab: string): Promise { + if (newTab === fullTab) return true + if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return false + } fullTab = newTab + return true } // --- Settings search (full mode) --- @@ -180,7 +202,10 @@ let highlightTimeout: ReturnType | undefined async function handleSearchSelect(item: SearchableSettingItem) { - handleNavigate(item.tabId) + const didNavigate = await handleNavigate(item.tabId) + if (!didNavigate) { + return + } if (item.settingKey) { clearTimeout(scrollTimeout) clearTimeout(highlightTimeout) @@ -202,7 +227,7 @@ }) /** Check if we need to warn about missing EE license key before proceeding */ - function proceedFromCore(callback: () => void) { + async function proceedFromCore(callback: () => void) { const leavingSettings = (mode === 'wizard' && wizardStep === 0) || (mode === 'full' && fullStep === 0) if (leavingSettings && isEeImage() && isLicenseKeyEmpty()) { @@ -210,12 +235,16 @@ showLicenseKeyWarning = true return } - saveAndProceed(callback) + await saveAndProceed(callback) } /** Auto-save dirty settings, then run the callback */ async function saveAndProceed(callback: () => void) { - if (yamlMode) { + if (isAiStepActive()) { + if (!((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return + } + } else if (yamlMode) { // In YAML mode, sync editor → form, then bulk-save everything if (!instanceSettings?.syncBeforeDiff()) return await instanceSettings.saveSettings() @@ -231,11 +260,14 @@ callback() } - function switchToFullMode() { + async function switchToFullMode() { mode = 'full' } - function switchToWizardMode() { + async function switchToWizardMode() { + if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return + } yamlMode = false fullStep = 0 mode = 'wizard' @@ -461,6 +493,13 @@ tab={settingsSteps[wizardStep].id} /> {/key} + {:else if wizardStep === AI_STEP_INDEX} + {:else} {@render accountSetupContent()} {/if} @@ -505,19 +544,28 @@ {/if}
    - { - const targetTab = categoryToTabMap[category] - if (targetTab) { - handleNavigate(targetTab) - } - }} - /> + {#if fullTab === 'ai' && !yamlMode} + + {:else} + { + const targetTab = categoryToTabMap[category] + if (targetTab) { + handleNavigate(targetTab) + } + }} + /> + {/if}
    {:else} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index d481346af2..db3a140c0b 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -19,10 +19,12 @@ import { OauthService, WorkspaceService, - ResourceService, SettingService, type AIConfig, - type ErrorHandler + type ErrorHandler, + type GetCopilotSettingsStateResponse, + type InstanceAISummary, + type GetSettingsResponse } from '$lib/gen' import { enterpriseLicense, @@ -60,7 +62,6 @@ convertDucklakeSettingsFromBackend, type DucklakeSettingsType } from '$lib/components/workspaceSettings/DucklakeSettings.svelte' - import { AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import CollapseLink from '$lib/components/CollapseLink.svelte' @@ -112,19 +113,12 @@ let publicAppRateLimitPerMinute: number | undefined = $state(undefined) let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined) - let aiProviders: Exclude = $state({}) - let codeCompletionModel: string | undefined = $state(undefined) - let defaultModel: string | undefined = $state(undefined) - let customPrompts: Record = $state({}) - let maxTokensPerModel: Record = $state({}) - - // Track initial AI config for unsaved changes detection - let initialAiProviders: Exclude = $state({}) - let initialCodeCompletionModel: string | undefined = $state(undefined) - let initialDefaultModel: string | undefined = $state(undefined) - let initialCustomPrompts: Record = $state({}) - let initialMaxTokensPerModel: Record = $state({}) - + let hasInstanceAiConfig = $state(false) + let usesInstanceAiConfig = $state(false) + let instanceAiSummary: InstanceAISummary | undefined = $state(undefined) + let aiInitialConfig: AIConfig | undefined = $state(undefined) + let aiSettingsComponent: AISettings | undefined = $state(undefined) + let hasAiSettingsChanges = $state(false) // Track initial deploy settings for unsaved changes detection let initialWorkspaceToDeployTo: string | undefined = $state(undefined) let initialDeployUiSettings: { @@ -227,14 +221,6 @@ return currentValue !== initialValue }) - // Derived state for checking unsaved changes in AI settings - let hasAiSettingsChanges = $derived.by(() => { - if (tab !== 'ai') return false - const changes = getAiSettingsInitialAndModifiedValues() - if (!changes.savedValue || !changes.modifiedValue) return false - return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) - }) - // Derived state for checking unsaved changes in deployment settings let hasDeploySettingsChanges = $derived.by(() => { if (tab !== 'deploy_to') return false @@ -320,8 +306,6 @@ $page.url.searchParams.get('tab') === 'teams' ? 'teams_commands' : 'slack_commands' ) - let usingOpenaiClientCredentialsOauth = $state(false) - let loadedSettings = $state(false) let oauths: Record = $state({}) @@ -489,7 +473,17 @@ } async function loadSettings(): Promise { - const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) + const [settings, copilotSettingsState]: [ + GetSettingsResponse, + GetCopilotSettingsStateResponse + ] = await Promise.all([ + WorkspaceService.getSettings({ + workspace: $workspaceStore! + }), + WorkspaceService.getCopilotSettingsState({ + workspace: $workspaceStore! + }) + ]) slack_team_name = settings.slack_name teams_team_id = settings.teams_team_id teams_team_name = settings.teams_team_name @@ -508,23 +502,10 @@ workspaceToDeployTo = settings.deploy_to webhook = settings.webhook - aiProviders = settings.ai_config?.providers ?? {} - defaultModel = settings.ai_config?.default_model?.model - codeCompletionModel = settings.ai_config?.code_completion_model?.model - customPrompts = settings.ai_config?.custom_prompts ?? {} - maxTokensPerModel = settings.ai_config?.max_tokens_per_model ?? {} - for (const mode of Object.values(AIMode)) { - if (!(mode in customPrompts)) { - customPrompts[mode] = '' - } - } - - // Store initial AI config state for unsaved changes detection - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) + aiInitialConfig = settings.ai_config ?? {} + hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config + usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config + instanceAiSummary = copilotSettingsState.instance_ai_summary const errorHandler = settings.error_handler as | { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean } | undefined @@ -600,12 +581,6 @@ // Store initial success handler state for unsaved changes detection initialSuccessHandlerScriptPath = successHandlerScriptPath - // check openai_client_credentials_oauth - usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({ - workspace: $workspaceStore!, - path: 'openai_client_credentials_oauth' - }) - loadedSettings = true } @@ -816,36 +791,6 @@ ) } - // Function to check if there are unsaved changes in AI settings - function getAiSettingsInitialAndModifiedValues() { - const savedValue = { - aiProviders: initialAiProviders, - defaultModel: initialDefaultModel, - codeCompletionModel: initialCodeCompletionModel, - customPrompts: initialCustomPrompts, - maxTokensPerModel: initialMaxTokensPerModel - } - - const modifiedValue = { - aiProviders: aiProviders, - defaultModel: defaultModel, - codeCompletionModel: codeCompletionModel, - customPrompts: customPrompts, - maxTokensPerModel: maxTokensPerModel - } - - return { savedValue, modifiedValue } - } - - // Function to discard unsaved AI settings changes - function discardAiSettingsChanges() { - aiProviders = clone(initialAiProviders) - defaultModel = initialDefaultModel - codeCompletionModel = initialCodeCompletionModel - customPrompts = clone(initialCustomPrompts) - maxTokensPerModel = clone(initialMaxTokensPerModel) - } - // Function to check if there are unsaved changes in storage settings function getStorageSettingsInitialAndModifiedValues() { return { @@ -1017,7 +962,9 @@ case 'windmill_data_tables': return dataTableSettingsComponent?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} } case 'ai': - return getAiSettingsInitialAndModifiedValues() + return hasAiSettingsChanges + ? { savedValue: { changed: false }, modifiedValue: { changed: true } } + : { savedValue: {}, modifiedValue: {} } case 'windmill_lfs': return getStorageSettingsInitialAndModifiedValues() case 'volume_storage': @@ -1059,7 +1006,7 @@ function discardAllChanges() { switch (tab) { case 'ai': - discardAiSettingsChanges() + aiSettingsComponent?.discard() break case 'windmill_lfs': discardStorageSettingsChanges() @@ -1830,21 +1777,19 @@ export async function main( /> {:else if tab == 'ai'} { - // Update initial state after successful save - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) + bind:this={aiSettingsComponent} + initialConfig={aiInitialConfig} + bind:hasUnsavedChanges={hasAiSettingsChanges} + {hasInstanceAiConfig} + {usesInstanceAiConfig} + {instanceAiSummary} + onSave={(copilotSettingsState) => { + if (!copilotSettingsState) { + return + } + hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config + usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config + instanceAiSummary = copilotSettingsState.instance_ai_summary }} /> {:else if tab == 'windmill_data_tables'} From d578e40101a838d3dffda14157cf72ee4d5a93c0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 21:22:35 +0000 Subject: [PATCH 015/153] feat: add selfApproval option to WAC + inline approval buttons (#8440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add selfApproval option to WAC waitForApproval + inline approval buttons Add self-approval configuration to WAC workflows and inline approve/reject buttons in WorkflowTimeline. - TS SDK: add selfApproval option to waitForApproval() - Python SDK: add self_approval param to wait_for_approval() - Backend: store approval_conditions in flow_status for WAC, enforce self-approval checks on resume endpoints - Frontend: show Approve/Reject buttons in timeline with form support (EE), gated by user permissions Co-Authored-By: Claude Opus 4.6 (1M context) * fix: revert sqlx query change + regenerate system prompts - Revert get_suspended_flow_info to use original sqlx::query_as! with COALESCE to avoid sqlx offline cache mismatch in CI - Detect WAC by checking if FlowStatus parsing fails + suspend > 0 - Re-fetch flow_status column separately for WAC approval conditions - Regenerate auto-generated system prompt files for SDK changes Co-Authored-By: Claude Opus 4.6 (1M context) * feat: use resume URLs for WAC inline approval buttons - Backend generates HMAC-signed resume/cancel URLs when creating WAC approval, stores them in timeline entry and approval meta - Frontend uses anonymous resume endpoint (like classic flows) with fallback to resumeSuspendedFlowAsOwner for admins - Buttons show for everyone when URLs are present; server-side self_approval_disabled check enforces restrictions - Show warning for admins/owners when self-approval is disabled - selfApproval: false requires EE (errors at dispatch on CE) - self_approval_disabled check moved outside user_auth_required gate so it works independently - WAC detection no longer requires task import Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add resume_suspended and approval_info endpoints - New approval_token DB table for token-based approval access - New POST /jobs_u/flow/resume_suspended/{job_id} endpoint: - OptAuthed: works with login or approval_token - Checks approval_conditions (self_approval, groups, auth) - Admins/owners bypass rules - New GET /jobs_u/flow/approval_info/{job_id} endpoint: - Returns form, rules, can_approve status - HMAC anonymous endpoint now bypasses all approval_conditions (secret = full capability) - getResumeUrls approvalPage URL now uses token format - WAC approval dispatch generates and stores approval tokens - Mark resumeSuspendedFlowAsOwner as legacy Co-Authored-By: Claude Opus 4.6 (1M context) * feat: simplify frontend to use resume_suspended endpoint - OpenAPI spec updated with resume_suspended and approval_info endpoints - WorkflowTimeline: removed URL parsing, now calls single resumeSuspended endpoint for both approve and reject - Buttons show for any logged-in user viewing the job (backend enforces authorization rules) - Kept self-approval warning for admins Co-Authored-By: Claude Opus 4.6 (1M context) * feat: stateless approval tokens, new approval page, FlowStatusWaitingForEvents update - Replace DB-stored approval tokens with stateless HMAC derivation: token = HMAC(workspace_key, job_id + "approval_token") Verifiable without DB lookup, not reversible to resume secret - Drop approval_token migration (no DB table needed) - FlowStatusWaitingForEvents: use resumeSuspended endpoint instead of URL parsing + resumeSuspendedFlowAsOwner - New approval page route /approve/{ws}/{job}?token= that uses approval_info and resume_suspended endpoints - Old approval page route kept for back-compat Co-Authored-By: Claude Opus 4.6 (1M context) * feat: match old approval page content in new approval page - Add FlowMetadata, JobArgs, FlowGraphV2, DisplayResult - Add approvers with tooltips, flow arguments section - Add admin self-approval bypass warning - Add "Open run details" link - Fetch full job alongside approval_info for all UI data Co-Authored-By: Claude Opus 4.6 (1M context) * fix: filter _MODULES from args, show 'workflow' for WAC approvals Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove deno template from approval/prompt SuspendDrawer Co-Authored-By: Claude Opus 4.6 (1M context) * fix: approval page form display + hide deno from approval script picker - Fix form schema rendering on new approval page by wrapping flat WAC form schemas in { properties, order } for SchemaForm - Hide deno from the approval step language picker in flow editor Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove deno from canHaveApproval in script_helpers.ts The insert menu uses canHaveApproval() from script_helpers.ts via FlowInputsQuick, not the displayLang function in FlowInputs.svelte. Revert the unnecessary FlowInputs.svelte change. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: return form schema and description in approval_info for classic flows The approval_info endpoint was returning None for form_schema on classic flows. Now fetches raw_flow to get suspend.resume_form schema, hide_cancel, and the step's completed result for description. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: inline Login component on approval page instead of redirect Show the Login component directly on the approval page when authentication is required. On successful login, reloads user and approval info without navigating away. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show resume buttons for all users, not just owners The resume_suspended endpoint handles authorization server-side, so the frontend should always show the buttons. Remove isOwner gate and the "cannot resume" message. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: prevent layout shift on resume by removing spinner from cancel button Co-Authored-By: Claude Opus 4.6 (1M context) * fix: prevent resume button expansion by using disabled instead of loading The loading prop adds a Loader2 spinner that expands the button width. Use disabled={loading} instead to prevent layout shift. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: approval page login redirects back with full page reload Set rd to the full URL (starts with http) so Login.redirectUser() uses window.location.href instead of goto(), triggering a full page reload after login. This ensures the approval page re-fetches data as an authenticated user. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: fetch flow definition from flow_version when raw_flow is null Deployed flows don't store raw_flow on the job. Fall back to flow_version table using runnable_id to get suspend settings (form schema, hide_cancel) for the approval_info endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: show specific reasons when user cannot approve Display whether denial is due to self-approval being disabled, required group membership, or both. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: support both nested and flat form schema in waitForApproval Users can now pass either: waitForApproval({ form: { schema: { name: { type: "string" } } } }) or: waitForApproval({ form: { name: { type: "string" } } }) Both WorkflowTimeline and approval page handle both formats. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: convert sqlx query macros to non-macro for CI offline cache Replace sqlx::query! and sqlx::query_scalar! with sqlx::query and sqlx::query_as to avoid SQLX_OFFLINE cache misses in CI. Also remove unused LogIn import from approval page. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: suppress dead code warning + unused isOwner variable - Add #[allow(dead_code)] to without_flow method (CI -D warnings) - Rename isOwner to _isOwner in FlowStatusWaitingForEvents (unused) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: security and robustness fixes from PR review - Add workspace_id verification in resume_suspended to prevent cross-workspace approval (#3) - Fix token leakage: use relative path for login redirect instead of full URL with token (#4) - Handle getJob failure independently from approval_info so the page works for unauthenticated users (#7) - Clear error state on successful data load (#13) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback — shared token gen, rand resume_id, UX - Move generate_approval_token to windmill-common::variables (shared between windmill-api and windmill-worker, eliminates duplicate HMAC) - Use rand::random::() for resume_id instead of DefaultHasher - Stop polling after approve/reject on approval page - Add cancelLoading state to WorkflowTimeline Reject button Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 1 + backend/windmill-api/openapi.yaml | 123 ++++ backend/windmill-api/src/jobs.rs | 530 ++++++++++++++++-- backend/windmill-common/src/variables.rs | 18 + backend/windmill-worker/Cargo.toml | 1 + backend/windmill-worker/src/bun_executor.rs | 89 ++- backend/windmill-worker/src/wac_executor.rs | 24 +- cli/src/guidance/skills.ts | 15 +- .../components/FlowStatusViewerInner.svelte | 1 + .../FlowStatusWaitingForEvents.svelte | 150 ++--- .../lib/components/WorkflowTimeline.svelte | 145 ++++- .../flows/content/SuspendDrawer.svelte | 18 - .../lib/components/runs/JobRunsPreview.svelte | 1 + .../components/scriptEditor/LogPanel.svelte | 1 + frontend/src/lib/script_helpers.ts | 2 +- .../(root)/(logged)/run/[...run]/+page.svelte | 1 + .../approve/[workspace]/[job]/+page.svelte | 359 ++++++++++++ python-client/wmill/wmill/client.py | 11 +- system_prompts/auto-generated/prompts.ts | 9 +- system_prompts/auto-generated/script.md | 9 +- system_prompts/auto-generated/sdks/python.md | 7 +- .../auto-generated/sdks/typescript.md | 2 +- .../skills/write-script-bun/SKILL.md | 2 +- .../skills/write-script-bunnative/SKILL.md | 2 +- .../skills/write-script-deno/SKILL.md | 2 +- .../skills/write-script-nativets/SKILL.md | 2 +- .../skills/write-script-python3/SKILL.md | 7 +- typescript-client/client.ts | 3 + 28 files changed, 1313 insertions(+), 222 deletions(-) create mode 100644 frontend/src/routes/approve/[workspace]/[job]/+page.svelte diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f044478b25..30138085c9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17500,6 +17500,7 @@ dependencies = [ "gcp_auth", "git-version", "hex", + "hmac", "hudsucker", "hyper-http-proxy", "hyper-tls", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d55596bc72..6576633319 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -11088,6 +11088,129 @@ paths: "200": description: Interactive slack approval message sent successfully + /w/{workspace}/jobs_u/flow/resume_suspended/{job_id}: + post: + summary: resume or cancel a suspended flow/WAC job + description: > + Resume or cancel a suspended flow/WAC job. Uses approval rules to + determine authorization. Either a valid approval_token or an + authenticated session is required. + operationId: resumeSuspended + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: job_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + payload: + description: payload to send to the resumed job + approval_token: + type: string + description: approval token for unauthenticated access + approved: + type: boolean + description: whether to approve (true) or cancel (false) the job + default: true + responses: + "201": + description: job resumed + content: + text/plain: + schema: + type: string + + /w/{workspace}/jobs_u/flow/approval_info/{job_id}: + get: + summary: get approval info for a suspended flow/WAC job + description: > + Get approval info for a suspended flow/WAC job. Returns form schema, + approval rules, and whether the current user can approve. Either a + valid token query parameter or an authenticated session is required. + operationId: getApprovalInfo + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: job_id + in: path + required: true + schema: + type: string + format: uuid + - name: token + in: query + required: false + schema: + type: string + description: approval token for unauthenticated access + responses: + "200": + description: approval info + content: + application/json: + schema: + type: object + required: + - flow_id + - can_approve + - user_auth_required + - approvers + properties: + flow_id: + type: string + format: uuid + form_schema: + description: form schema for the approval step + description: + description: description of the approval step + approval_conditions: + type: object + properties: + user_auth_required: + type: boolean + user_groups_required: + type: array + items: + type: string + self_approval_disabled: + type: boolean + required: + - user_auth_required + - user_groups_required + - self_approval_disabled + can_approve: + type: boolean + description: whether the current user/token holder can approve + user_auth_required: + type: boolean + description: whether user authentication is required to approve + hide_cancel: + type: boolean + description: whether to hide the cancel button in the UI + approvers: + type: array + items: + type: object + required: + - resume_id + - approver + properties: + resume_id: + type: integer + approver: + type: string + /w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}: get: summary: resume a job for a suspended flow diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index a99b2737a8..bde3b81a14 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -103,7 +103,7 @@ use windmill_common::{ cache, db::UserDB, error::{self, to_anyhow, Error}, - flow_status::{Approval, FlowStatus, FlowStatusModule}, + flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule}, flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue}, jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode}, oauth2::HmacSha256, @@ -401,6 +401,8 @@ pub fn workspace_unauthed_service() -> Router { post(cancel_persistent_script_api), ) .route("/queue/force_cancel/:id", post(force_cancel)) + .route("/flow/resume_suspended/:job_id", post(resume_suspended)) + .route("/flow/approval_info/:job_id", get(get_approval_info)) } pub fn global_root_service() -> Router { @@ -1058,6 +1060,7 @@ impl<'a> GetQuery<'a> { Self { with_code: false, ..self } } + #[allow(dead_code)] fn without_flow(self) -> Self { Self { with_flow: false, ..self } } @@ -2181,7 +2184,7 @@ pub async fn resume_suspended_flow_as_owner( ) -> error::Result { let mut tx = db.begin().await?; - let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?; + let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?; let flow_path = flow.script_path.as_deref().unwrap_or_else(|| ""); require_owner_of_path(&authed, flow_path)?; @@ -2189,10 +2192,17 @@ pub async fn resume_suspended_flow_as_owner( // Check approval conditions (self-approval, required groups, etc.) if let Some(ref flow_status_value) = flow.flow_status { - if let Ok(flow_status) = serde_json::from_value::(flow_status_value.clone()) { - let trigger_email = flow.email.as_deref().unwrap_or(""); - conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?; - } + let trigger_email = flow.email.as_deref().unwrap_or(""); + let ac = serde_json::from_value::(flow_status_value.clone()) + .ok() + .and_then(|fs| fs.approval_conditions) + .or_else(|| { + // WAC flows store approval_conditions directly in flow_status JSONB + flow_status_value + .get("approval_conditions") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + }); + conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?; } let value = value.unwrap_or(serde_json::Value::Null); @@ -2208,12 +2218,426 @@ pub async fn resume_suspended_flow_as_owner( ) .await?; - resume_immediately_if_relevant(flow, job_id, &mut tx).await?; + if is_wac { + // WAC: directly decrement suspend counter + if flow.suspend > 0 { + sqlx::query!( + "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1", + flow.id, + ) + .execute(&mut *tx) + .await?; + } + } else { + resume_immediately_if_relevant(flow, job_id, &mut tx).await?; + } tx.commit().await?; Ok(StatusCode::CREATED) } +// --- New approval system endpoints --- + +use windmill_common::variables::generate_approval_token; + +/// Verify an approval token against the workspace key + job_id. +async fn validate_approval_token( + db: &DB, + token: &str, + job_id: Uuid, + workspace_id: &str, +) -> error::Result<()> { + let expected = generate_approval_token(workspace_id, job_id, db).await?; + if token != expected { + return Err(Error::NotAuthorized("Invalid approval token".to_string())); + } + Ok(()) +} + +#[derive(Deserialize)] +struct ResumeSuspendedBody { + payload: Option, + approval_token: Option, + approved: Option, +} + +async fn resume_suspended( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, job_id)): Path<(String, Uuid)>, + Json(body): Json, +) -> error::Result { + let approved = body.approved.unwrap_or(true); + let value = body.payload.unwrap_or(serde_json::Value::Null); + + // Determine if we have a valid authed user or token + let has_token = if let Some(ref token) = body.approval_token { + validate_approval_token(&db, token, job_id, &w_id) + .await + .is_ok() + } else { + false + }; + + if opt_authed.is_none() && !has_token { + return Err(Error::NotAuthorized( + "Must be logged in or provide a valid approval token".to_string(), + )); + } + + let mut tx = db.begin().await?; + + // Resolve the suspended flow (works for both WAC and classic flows) + let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?; + + // Verify the job belongs to this workspace + let job_workspace: Option = + sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1") + .bind(&flow.id) + .fetch_optional(&mut *tx) + .await?; + if job_workspace.as_deref() != Some(w_id.as_str()) { + return Err(Error::NotFound( + "Job not found in this workspace".to_string(), + )); + } + + // Check approval conditions + let approval_conditions = if is_wac { + flow.flow_status + .as_ref() + .and_then(|v| v.get("approval_conditions")) + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + } else { + flow.flow_status + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .and_then(|fs| fs.approval_conditions) + }; + + if let Some(ref ac) = approval_conditions { + if ac.user_auth_required && opt_authed.is_none() { + return Err(Error::NotAuthorized( + "This approval requires a logged-in user. Please sign in.".to_string(), + )); + } + } + + // If logged in, check authorization rules + if let Some(ref authed) = opt_authed { + let is_admin = authed.is_admin; + let is_owner = flow + .script_path + .as_deref() + .map(|p| require_owner_of_path(authed, p).is_ok()) + .unwrap_or(false); + + if !is_admin && !is_owner { + let trigger_email = flow.email.as_deref().unwrap_or(""); + conditionally_require_authed_user( + Some(authed.clone()), + approval_conditions.clone(), + trigger_email, + )?; + } + } else if !has_token { + return Err(Error::NotAuthorized( + "Must be logged in or provide a valid approval token".to_string(), + )); + } + + // Generate a unique resume_id + let resume_id: u32 = rand::random(); + + // Check for duplicate + let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)") + .bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128)) + .fetch_one(&mut *tx) + .await?; + + if exists { + return Err(Error::BadRequest("Resume request already sent".to_string())); + } + + let approver_value = opt_authed.as_ref().map(|a| a.username.clone()); + + insert_resume_job( + resume_id, + resume_job_id, + &flow, + value, + approver_value.clone(), + approved, + &mut tx, + ) + .await?; + + if !approved { + sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1") + .bind(&flow.id) + .execute(&mut *tx) + .await?; + } else if is_wac { + if flow.suspend > 0 { + sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1") + .bind(&flow.id) + .execute(&mut *tx) + .await?; + } + } else { + resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?; + } + + let approver = approver_value.unwrap_or_else(|| "anonymous".to_string()); + let audit_author = if let Some(ref authed) = opt_authed { + AuditAuthor::from(authed) + } else { + AuditAuthor { + email: approver.clone(), + username: approver.clone(), + username_override: None, + token_prefix: None, + } + }; + + audit_log( + &mut *tx, + &audit_author, + "jobs.suspend_resume", + ActionKind::Update, + &w_id, + Some( + &serde_json::json!({ + "approved": approved, + "job_id": job_id, + "details": if approved { + format!("Approved by {}", &approver) + } else { + format!("Cancelled by {}", &approver) + } + }) + .to_string(), + ), + None, + ) + .await?; + + tx.commit().await?; + Ok(StatusCode::CREATED) +} + +#[derive(Deserialize)] +struct ApprovalInfoQuery { + token: Option, +} + +#[derive(Serialize)] +struct ApprovalInfo { + flow_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + form_schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + approval_conditions: Option, + can_approve: bool, + user_auth_required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + hide_cancel: Option, + approvers: Vec, +} + +async fn get_approval_info( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, job_id)): Path<(String, Uuid)>, + Query(query): Query, +) -> error::Result> { + // Validate access: either logged in or valid token + let has_token = if let Some(ref token) = query.token { + validate_approval_token(&db, token, job_id, &w_id) + .await + .is_ok() + } else { + false + }; + + if opt_authed.is_none() && !has_token { + return Err(Error::NotAuthorized( + "Must be logged in or provide a valid approval token".to_string(), + )); + } + + // Fetch job info + #[derive(sqlx::FromRow)] + struct ApprovalJobRow { + id: Uuid, + script_path: Option, + email: String, + flow_status: Option, + workflow_as_code_status: Option, + } + let row = sqlx::query_as::<_, ApprovalJobRow>( + "SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email, + s.flow_status, s.workflow_as_code_status + FROM v2_job j + LEFT JOIN v2_job_status s ON s.id = j.id + WHERE j.id = $1 AND j.workspace_id = $2", + ) + .bind(&job_id) + .bind(&w_id) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?; + + let is_wac = row.workflow_as_code_status.is_some(); + + // Extract approval info based on WAC vs classic flow + let (form_schema, description, approval_conditions, hide_cancel) = if is_wac { + let approval_meta = row + .workflow_as_code_status + .as_ref() + .and_then(|v| v.get("_approval")); + let form = approval_meta.and_then(|m| m.get("form").cloned()); + let ac = row + .flow_status + .as_ref() + .and_then(|v| v.get("approval_conditions")) + .and_then(|v| serde_json::from_value::(v.clone()).ok()); + (form, None, ac, None) + } else { + let fs = row + .flow_status + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()); + let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone()); + + // For classic flows, form/description come from the flow definition and step result + let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1)); + + // Fetch flow definition to get suspend settings (form schema, hide_cancel). + // Try raw_flow on the job first, fall back to flow_version for deployed flows. + let raw_flow: Option = { + let from_job: Option = sqlx::query_scalar( + "SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2", + ) + .bind(&job_id) + .bind(&w_id) + .fetch_optional(&db) + .await? + .flatten(); + + if let Some(v) = from_job { + serde_json::from_value(v).ok() + } else { + // Deployed flow: fetch from flow_version using runnable_id + let from_version: Option = sqlx::query_scalar( + "SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \ + WHERE j.id = $1 AND j.workspace_id = $2", + ) + .bind(&job_id) + .bind(&w_id) + .fetch_optional(&db) + .await? + .flatten(); + from_version.and_then(|v| serde_json::from_value(v).ok()) + } + }; + + let suspend_module = raw_flow + .as_ref() + .and_then(|rf| approval_step.and_then(|s| rf.modules.get(s))); + let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref()); + + let form = suspend_settings + .and_then(|s| s.resume_form.as_ref()) + .map(|rf| serde_json::json!(rf)); + let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false)); + + // Fetch description and default_args from the step's completed job result + let step_job_id = fs + .as_ref() + .and_then(|s| approval_step.and_then(|step| s.modules.get(step))) + .and_then(|m| m.job()); + let (desc, _default_args) = if let Some(sjid) = step_job_id { + let result: Option = sqlx::query_scalar( + "SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2", + ) + .bind(sjid) + .bind(&w_id) + .fetch_optional(&db) + .await? + .flatten(); + let desc = result.as_ref().and_then(|r| r.get("description").cloned()); + let da = result.as_ref().and_then(|r| r.get("default_args").cloned()); + (desc, da) + } else { + (None, None) + }; + + (form, desc, ac, hc) + }; + + let user_auth_required = approval_conditions + .as_ref() + .map(|ac| ac.user_auth_required) + .unwrap_or(false); + + // Determine if current user can approve + let can_approve = if let Some(ref authed) = opt_authed { + if authed.is_admin { + true + } else { + let is_owner = row + .script_path + .as_deref() + .map(|p| require_owner_of_path(authed, p).is_ok()) + .unwrap_or(false); + if is_owner { + true + } else { + let trigger_email = row.email.as_str(); + conditionally_require_authed_user( + Some(authed.clone()), + approval_conditions.clone(), + trigger_email, + ) + .is_ok() + } + } + } else { + // Not logged in — can approve only if no auth required + !user_auth_required + }; + + // Get existing approvers + let approvers: Vec = sqlx::query_as::<_, (i32, Option)>( + "SELECT resume_id, approver FROM resume_job WHERE flow = $1", + ) + .bind(&job_id) + .fetch_all(&db) + .await? + .into_iter() + .map(|(rid, approver)| Approval { + resume_id: rid as u16, + approver: approver.unwrap_or_else(|| "anonymous".to_string()), + }) + .collect(); + + Ok(Json(ApprovalInfo { + flow_id: row.id, + form_schema, + description, + approval_conditions, + can_approve, + user_auth_required, + hide_cancel, + approvers, + })) +} + +// --- End new approval system endpoints --- + pub async fn resume_suspended_job( authed: Option, opt_tokened: OptTokened, @@ -2255,26 +2679,8 @@ async fn resume_suspended_job_internal( // Get flow info - works for step-level, flow-level, and WAC approval let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?; - // For step-level resumes, verify user auth and flow status - // For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet - // For WAC approvals, skip flow status checks (there is no flow) - if !is_flow_level && !is_wac { - let parent_flow = GetQuery::new() - .without_logs() - .without_code() - .without_flow() - .fetch(&db, &flow_info.id, &w_id) - .await?; - let flow_status = parent_flow - .flow_status() - .ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?; - - let trigger_email = match &parent_flow { - Job::CompletedJob(job) => &job.email, - Job::QueuedJob(job) => &job.email, - }; - conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?; - } + // HMAC secret = full capability. Skip approval_conditions checks. + // Authorization rules are enforced by the new resume_suspended endpoint instead. let exists = sqlx::query_scalar!( r#" @@ -2540,7 +2946,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI async fn get_suspended_flow_info<'c>( job_id: Uuid, tx: &mut Transaction<'c, Postgres>, -) -> error::Result<(FlowInfo, Uuid)> { +) -> error::Result<(FlowInfo, Uuid, bool)> { let flow = sqlx::query_as!( FlowInfo, r#" @@ -2553,7 +2959,9 @@ async fn get_suspended_flow_info<'c>( .fetch_optional(&mut **tx) .await? .ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?; - let job_id = flow + + // Try to extract step job_id from FlowStatus modules (classic flow path) + let step_job_id = flow .flow_status .as_ref() .and_then(|v| serde_json::from_value::(v.clone()).ok()) @@ -2562,8 +2970,31 @@ async fn get_suspended_flow_info<'c>( _ => None, }); - if let Some(job_id) = job_id { - Ok((flow, job_id)) + if let Some(step_job_id) = step_job_id { + // Classic flow + Ok((flow, step_job_id, false)) + } else if flow.suspend > 0 { + // WAC approval: no FlowStatus modules, but the job is suspended + // The flow_status here comes from COALESCE(flow_status, workflow_as_code_status), + // so for WAC it may contain approval_conditions from flow_status column + // or the WAC checkpoint from workflow_as_code_status column. + // We need the approval_conditions which are in flow_status column. + // Re-fetch just flow_status (without COALESCE fallback) for the auth check. + let flow_status_only: Option = + sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1") + .bind(&job_id) + .fetch_optional(&mut **tx) + .await? + .flatten(); + + let flow = FlowInfo { + id: flow.id, + flow_status: flow_status_only, + suspend: flow.suspend, + script_path: flow.script_path, + email: flow.email, + }; + Ok((flow, job_id, true)) } else { Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into()) } @@ -2640,7 +3071,11 @@ pub async fn get_suspended_job_flow( Job::CompletedJob(job) => &job.email, Job::QueuedJob(job) => &job.email, }; - conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?; + conditionally_require_authed_user( + authed.clone(), + flow_status.approval_conditions.clone(), + trigger_email, + )?; let approvers_from_status = match flow_module_status { FlowStatusModule::Success { approvers, .. } => approvers.to_owned(), @@ -2681,16 +3116,25 @@ pub async fn get_suspended_job_flow( fn conditionally_require_authed_user( _authed: Option, - flow_status: FlowStatus, + approval_conditions_opt: Option, _trigger_email: &str, ) -> error::Result<()> { - let approval_conditions_opt = flow_status.approval_conditions; - if approval_conditions_opt.is_none() { return Ok(()); } let approval_conditions = approval_conditions_opt.unwrap(); + // Check self-approval independently of user_auth_required + if approval_conditions.self_approval_disabled { + if let Some(ref authed) = _authed { + if !authed.is_admin && authed.email.eq(_trigger_email) { + return Err(Error::PermissionDenied( + "Self-approval is disabled for this flow step".to_string(), + )); + } + } + } + if approval_conditions.user_auth_required { { #[cfg(not(feature = "enterprise"))] @@ -2708,13 +3152,6 @@ fn conditionally_require_authed_user( let authed = _authed.unwrap(); if !authed.is_admin { - if approval_conditions.self_approval_disabled && authed.email.eq(_trigger_email) - { - return Err(Error::PermissionDenied( - "Self-approval is disabled for this flow step".to_string(), - )); - } - if !approval_conditions.user_groups_required.is_empty() { #[cfg(feature = "enterprise")] { @@ -2860,11 +3297,18 @@ pub async fn get_resume_urls_internal( .map(|x| format!("?approver={}", encode(x))) .unwrap_or_else(String::new); + // Generate approval token for the new approval page URL. + // The token targets the parent flow/WAC job for proper resolution. + let approval_target_id = get_flow_id_for_job(&db, job_id) + .await + .unwrap_or(target_job_id); + let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?; + let base_url_str = BASE_URL.read().await.clone(); let base_url = base_url_str.as_str(); let res = ResumeUrls { approvalPage: format!( - "{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}" + "{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}" ), cancel: build_resume_url( "cancel", diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 8129f39b3f..e7595f9d1b 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -140,6 +140,24 @@ pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result crate::error::Result { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let key = get_workspace_key(w_id, db).await?; + let mut mac = Hmac::::new_from_slice(key.as_bytes()) + .map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?; + mac.update(job_id.as_bytes()); + mac.update(b"approval_token"); + Ok(hex::encode(mac.finalize().into_bytes())) +} + pub async fn get_secret_value_as_admin( db: &DB, w_id: &str, diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index ed82298038..c1e2a4927a 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -110,6 +110,7 @@ gcp_auth = { workspace = true, optional = true } rust_decimal.workspace = true jsonwebtoken.workspace = true sha2.workspace = true +hmac.workspace = true pem = { workspace = true, optional = true } urlencoding.workspace = true nix.workspace = true diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index f0ed8793a7..7dbaa6723d 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1504,7 +1504,7 @@ async function run() {{ return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null, started_at: dispatch.started_at, duration_ms: dispatch.duration_ms }}; }} if (dispatch.mode === "approval") {{ - return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form }}; + return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled }}; }} if (dispatch.mode === "sleep") {{ return {{ type: "sleep", key: dispatch.key, seconds: dispatch.seconds }}; @@ -2634,7 +2634,7 @@ pub async fn handle_wac_v2_output( job.id, num_steps ))) } - WacOutput::Approval { key, timeout, form } => { + WacOutput::Approval { key, timeout, form, self_approval_disabled } => { let db = match conn { Connection::Sql(db) => db, _ => { @@ -2676,11 +2676,91 @@ pub async fn handle_wac_v2_output( .await .map_err(|e| error::Error::internal_err(format!("Failed to save checkpoint: {e}")))?; + // Store approval_conditions in flow_status for resume endpoint auth checks + let sad = self_approval_disabled.unwrap_or(false); + if sad { + #[cfg(not(feature = "enterprise"))] + return Err(error::Error::ExecutionErr( + "Disabling self-approval is an enterprise only feature".to_string(), + )); + + #[cfg(feature = "enterprise")] + { + use windmill_common::flow_status::ApprovalConditions; + let approval_conditions = ApprovalConditions { + user_auth_required: true, + user_groups_required: vec![], + self_approval_disabled: true, + }; + sqlx::query( + "UPDATE v2_job_status SET flow_status = JSONB_SET( + COALESCE(flow_status, '{}'::jsonb), + '{approval_conditions}', + $2::jsonb + ) WHERE id = $1", + ) + .bind(&job.id) + .bind(&serde_json::json!(approval_conditions)) + .execute(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!( + "Failed to save approval conditions: {e}" + )) + })?; + } + } + + // Generate resume URLs for the inline approval buttons. + // Use a hash of the step key as resume_id so each waitForApproval() + // in the same workflow gets a unique resume_job record. + let resume_id: u32 = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut hasher); + (hasher.finish() & 0xFFFF_FFFF) as u32 + }; + // Generate stateless approval token using shared utility + let approval_token = + windmill_common::variables::generate_approval_token(&job.workspace_id, job.id, db) + .await?; + + let (resume_url, cancel_url, approval_page_url) = { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use windmill_common::variables::get_workspace_key; + + let wkey = get_workspace_key(&job.workspace_id, db).await?; + let mut mac = Hmac::::new_from_slice(wkey.as_bytes()) + .map_err(|e| error::Error::internal_err(format!("HMAC key error: {e}")))?; + mac.update(job.id.as_bytes()); + mac.update(resume_id.to_be_bytes().as_ref()); + let signature = hex::encode(mac.finalize().into_bytes()); + + let base_url = windmill_common::BASE_URL.read().await.clone(); + let w_id = &job.workspace_id; + let job_id = &job.id; + + let resume = format!( + "{base_url}/api/w/{w_id}/jobs_u/resume/{job_id}/{resume_id}/{signature}" + ); + let cancel = format!( + "{base_url}/api/w/{w_id}/jobs_u/cancel/{job_id}/{resume_id}/{signature}" + ); + let approval_page = + format!("{base_url}/approve/{w_id}/{job_id}?token={approval_token}"); + (resume, cancel, approval_page) + }; + // Store approval form metadata for the approval page endpoint let approval_meta = serde_json::json!({ "key": key, "form": form, "timeout": timeout_secs as u32, + "self_approval_disabled": sad, + "resume": resume_url, + "cancel": cancel_url, + "approvalPage": approval_page_url, }); sqlx::query( "UPDATE v2_job_status SET workflow_as_code_status = jsonb_set( @@ -2705,6 +2785,11 @@ pub async fn handle_wac_v2_output( "started_at": &now_str, "name": key, "approval": true, + "self_approval_disabled": sad, + "form": form, + "resume": &resume_url, + "cancel": &cancel_url, + "approvalPage": &approval_page_url, }); let step_timeline_key = format!("_step/{}", key); sqlx::query( diff --git a/backend/windmill-worker/src/wac_executor.rs b/backend/windmill-worker/src/wac_executor.rs index 208012ccce..9b4ba3d92a 100644 --- a/backend/windmill-worker/src/wac_executor.rs +++ b/backend/windmill-worker/src/wac_executor.rs @@ -59,7 +59,13 @@ pub enum WacOutput { /// No child job is dispatched — the parent suspends directly and resumes /// when a user hits the resume/cancel endpoint. #[serde(rename = "approval")] - Approval { key: String, timeout: Option, form: Option }, + Approval { + key: String, + timeout: Option, + form: Option, + #[serde(default)] + self_approval_disabled: Option, + }, /// Server-side sleep — suspend the workflow for a duration without holding a worker. #[serde(rename = "sleep")] Sleep { key: String, seconds: u32 }, @@ -306,15 +312,13 @@ pub async fn prepare_checkpoint_for_resume( } /// Detect WAC v2 patterns in TypeScript/Bun code. -/// Checks for `import ... from "windmill-client"` containing workflow/task, +/// Checks for `import ... from "windmill-client"` containing workflow, /// skipping comment lines. Handles both single-line and multi-line imports. pub fn is_wac_v2_ts(code: &str) -> bool { let mut has_wac_import = false; let mut has_workflow = false; - let mut has_task = false; let mut in_import_block = false; let mut import_block_has_workflow = false; - let mut import_block_has_task = false; for line in code.lines() { let trimmed = line.trim(); if trimmed.starts_with("//") { @@ -328,34 +332,24 @@ pub fn is_wac_v2_ts(code: &str) -> bool { if trimmed.contains("workflow") { has_workflow = true; } - if trimmed.contains("task") { - has_task = true; - } in_import_block = false; } // Start of multi-line import: import { else if trimmed.starts_with("import") && trimmed.contains("{") && !trimmed.contains("}") { in_import_block = true; import_block_has_workflow = trimmed.contains("workflow"); - import_block_has_task = trimmed.contains("task"); } // Inside multi-line import block else if in_import_block { if trimmed.contains("workflow") { import_block_has_workflow = true; } - if trimmed.contains("task") { - import_block_has_task = true; - } // End of multi-line import: } from "windmill-client" if trimmed.contains("windmill-client") { has_wac_import = true; if import_block_has_workflow { has_workflow = true; } - if import_block_has_task { - has_task = true; - } in_import_block = false; } // End of import block but not windmill-client @@ -367,7 +361,7 @@ pub fn is_wac_v2_ts(code: &str) -> bool { has_workflow = true; } } - has_wac_import && has_workflow && has_task + has_wac_import && has_workflow } /// Detect WAC v2 patterns in Python code. diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index e1354302e6..662c55eb8e 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -740,7 +740,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -1403,7 +1403,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -2129,7 +2129,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -3069,7 +3069,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -4078,12 +4078,17 @@ async def sleep(seconds: int) # # Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index e9f2361629..2c580bb02b 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -2084,6 +2084,7 @@ stepResults={getStepResults(node.workflow_as_code_status)} result={node.result} success={node.type === 'Success'} + jobId={node.job_id} />
    {/if} diff --git a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte index 60f30b1adc..195a1d6fad 100644 --- a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte +++ b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte @@ -18,11 +18,9 @@ light?: boolean } - let { isOwner, workspaceId, job, light = false }: Props = $props() + let { isOwner: _isOwner, workspaceId, job, light = false }: Props = $props() let default_payload: object = $state({}) - let resumeUrl: string | undefined = $state(undefined) - let cancelUrl: string | undefined = $state(undefined) let description: any = $state(undefined) let hide_cancel = $state(false) @@ -49,8 +47,6 @@ defaultValues = JSON.parse(JSON.stringify(args)) default_payload = args - resumeUrl = job_result?.['resume'] - cancelUrl = job_result?.['cancel'] hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false schema = mergeSchema( job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {}, @@ -61,61 +57,19 @@ let loading = $state(false) async function continu(approve: boolean) { loading = true - if ((resumeUrl && approve) || (cancelUrl && !approve)) { - let split = (approve ? resumeUrl : cancelUrl)!.split('/') - let signatureUrl = split.pop() ?? '' - const regex = /([^?]+)(?:\?[^=]+=(\w+))?/ - - const matches = signatureUrl.match(regex) - - const signature = matches?.[1] - if (!signature) { - sendUserToast(`Could not parse signature: ${signatureUrl}`, true) - return - } - const approver = matches?.[2] || undefined - - let resumeId = -1 - let parsedResumeId = split.pop() ?? '' - try { - resumeId = new Number(parsedResumeId).valueOf() - } catch (e) { - console.error(`Could not parse resume id: ${parsedResumeId}`) - } - let jobId = split.pop() ?? '' - if (approve) { - await JobService.resumeSuspendedJobPost({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: jobId, - requestBody: default_payload as any, - resumeId, - signature, - approver - }) - } else { - await JobService.cancelSuspendedJobPost({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: jobId, - resumeId, - signature, - approver, - requestBody: {} - }) - } - } else { - if (approve) { - await JobService.resumeSuspendedFlowAsOwner({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: job?.id ?? '', - requestBody: default_payload as any - }) - } else { - await JobService.cancelQueuedJob({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: job?.id ?? '', - requestBody: {} - }) - } + try { + await JobService.resumeSuspended({ + workspace: workspaceId ?? $workspaceStore ?? '', + jobId: job?.id ?? '', + requestBody: { + payload: approve ? (default_payload as any) : undefined, + approved: approve + } + }) + } catch (e: any) { + sendUserToast(e?.body ?? e?.message ?? 'Failed', true) + } finally { + loading = false } } let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1) @@ -130,51 +84,41 @@
    {/if}
    - {#if isOwner || resumeUrl} -
    - {#if !hide_cancel} -
    -
    - {/if} +
    + {#if !hide_cancel}
    - +
    - - {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} -
    - -
    - - The payload is optional, it is passed to the following step through the `resume` - variable - - {/if} + {/if} +
    +
    - {:else} - You cannot resume the flow yourself without receiving the resume secret since you are not an - owner of {job.script_path} and the approval step did not contain the resume url at key `resume` - {/if} + + {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} +
    + +
    + + The payload is optional, it is passed to the following step through the `resume` variable + + {/if} +
    diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index 3dea7279d6..2ea5ebba50 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -1,6 +1,6 @@ {#if flow_status} @@ -167,22 +216,74 @@ sleep ({(v as any).sleep_duration_s}s)
    {:else if isApproval} -
    -
    - - - {v.name ?? stepKey(k)} - - {#if !isDone} - - - waiting + {@const selfApprovalDisabled = (v as any).self_approval_disabled === true} + {@const formSchema = (v as any).form?.schema ?? (v as any).form} + {@const hasForm = + formSchema && typeof formSchema === 'object' && Object.keys(formSchema).length > 0} + {@const canApprove = !isDone && jobId} +
    +
    +
    + + + {v.name ?? stepKey(k)} - {:else} - {msToSec(v.duration_ms ?? 0)}s + {#if !isDone} + + + waiting + + {#if canApprove} +
    + + +
    + {/if} + {:else} + {msToSec(v.duration_ms ?? 0)}s + {/if} +
    + {#if canApprove && selfApprovalDisabled && $userStore?.is_admin} +
    + Self-approval is disabled but allowed because you are an admin/owner +
    + {/if} + {#if canApprove && hasForm} +
    + {#if emptyString($enterpriseLicense)} + + {:else} + + {/if} +
    {/if}
    {:else} @@ -275,13 +376,13 @@ {@const result = stepResults[stepKey(k)]} {#if isDone && result !== undefined}
    -
    Result
    +
    Result
    {:else} -
    Step completed (no result)
    +
    Step completed (no result)
    {/if} {:else if loadingJobs[k] && !childJobs[k]}
    @@ -293,7 +394,7 @@ {#if job.logs || isRunning}
    -
    Logs
    +
    Logs
    {#if isDone && job.result !== undefined}
    -
    Result
    +
    Result
    diff --git a/frontend/src/lib/components/flows/content/SuspendDrawer.svelte b/frontend/src/lib/components/flows/content/SuspendDrawer.svelte index ee22349cac..3ac2afe001 100644 --- a/frontend/src/lib/components/flows/content/SuspendDrawer.svelte +++ b/frontend/src/lib/components/flows/content/SuspendDrawer.svelte @@ -39,27 +39,9 @@ render a cancel button, providing the operator with an option to cancel the step. e.g: - {#snippet content()} - - -
    {/if} diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index 271e2a765c..82845c4a88 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -158,6 +158,7 @@ result={previewJob?.result} success={previewJob?.success !== false} autoExpandResult + jobId={previewJob?.id} />
    {:else} diff --git a/frontend/src/lib/script_helpers.ts b/frontend/src/lib/script_helpers.ts index d05d78f6ef..6865637ba7 100644 --- a/frontend/src/lib/script_helpers.ts +++ b/frontend/src/lib/script_helpers.ts @@ -1601,7 +1601,7 @@ export function canHaveApproval(language: SupportedLanguage | undefined): boolea return false } - return ['python3', 'bun', 'deno'].includes(language) + return ['python3', 'bun'].includes(language) } export function canHaveFailure(language: SupportedLanguage | undefined): boolean { diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 5bf8f21ac5..5f7e426fe9 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -797,6 +797,7 @@ stepResults={getStepResults(job.workflow_as_code_status)} result={job.result} success={(job as any).success !== false} + jobId={job.id} />
    diff --git a/frontend/src/routes/approve/[workspace]/[job]/+page.svelte b/frontend/src/routes/approve/[workspace]/[job]/+page.svelte new file mode 100644 index 0000000000..6c9638a948 --- /dev/null +++ b/frontend/src/routes/approve/[workspace]/[job]/+page.svelte @@ -0,0 +1,359 @@ + + + + + + {#if error} +
    + {#if error.includes('logged in') || error.includes('sign in') || error.includes('Not authorized')} +
    + +

    Not Authorized

    +
    +

    {error}

    + + {:else if error.includes('Permission denied') || error.includes('Self-approval')} +
    + +

    Permission denied

    +
    +

    {error}

    + {:else} +
    + +

    Error

    +
    +

    {error}

    + {/if} +
    + {:else if approvalInfo} +
    +
    +

    Approvers

    +
    + {#if approvalInfo.approvers?.length > 0} +
      + {#each approvalInfo.approvers as a} +
    • +

      + {a.approver} + Unique id of approval: {a.resume_id} +

      +
    • + {/each} +
    + {:else} +

    + No current approvers for this step (approval steps can require more than one approval) +

    + {/if} +
    +
    +
    + {#if job && job.raw_flow} + + {/if} +
    +
    + + {#if !completed} +

    + {isWac ? 'Workflow' : 'Flow'} arguments +

    + + {/if} + +
    + +
    + {#if completed} + + The flow is not running anymore. You cannot cancel or resume it. + + {/if} + + {#if approvalInfo.description != undefined} + + {/if} + + {#if hasForm && !completed} + {#if emptyString($enterpriseLicense)} + + {:else} + + {/if} + {/if} + + {#if !completed && approvalInfo.can_approve} +
    + {#if approvalInfo.hide_cancel !== true} + + {:else} +
    + {/if} + +
    + {:else if !completed && !approvalInfo.can_approve} + {#if approvalInfo.user_auth_required && !$userStore} + + {:else} +
    +

    You are not authorized to approve this flow.

    + {#if approvalInfo.approval_conditions?.self_approval_disabled && $userStore && $userStore.email === (job as any)?.email} +

    Self-approval is disabled for this step.

    + {/if} + {#if approvalInfo.approval_conditions?.user_groups_required?.length > 0} +

    Only members of the following groups can approve: {approvalInfo.approval_conditions.user_groups_required.join(', ')}

    + {/if} +
    + {/if} + {:else if completed} + + {/if} + + {#if !completed && isSelfApprovalBypass} +
    + + As an administrator, by resuming or cancelling this stage of the flow, you bypass the + self-approval interdiction. + +
    + {/if} +
    + + + + {#if job && job.raw_flow && !completed} +

    Flow details

    +
    + +
    + {/if} + {:else} +

    Loading...

    + {/if} +
    diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index cdd3342771..d952584e5a 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -2463,7 +2463,7 @@ class WorkflowCtx: ) async def _wait_for_approval( - self, timeout: int = 1800, form: dict | None = None + self, timeout: int = 1800, form: dict | None = None, self_approval: bool = True ): key = self._alloc_key("approval") @@ -2479,6 +2479,7 @@ class WorkflowCtx: "key": key, "timeout": timeout, "form": form, + "self_approval_disabled": not self_approval, "steps": [], }) @@ -2762,6 +2763,7 @@ async def sleep(seconds: int): async def wait_for_approval( timeout: int = 1800, form: dict | None = None, + self_approval: bool = True, ) -> dict: """Suspend the workflow and wait for an external approval. @@ -2770,6 +2772,11 @@ async def wait_for_approval( Returns a dict with ``value`` (form data), ``approver``, and ``approved``. + Args: + timeout: Approval timeout in seconds (default 1800). + form: Optional form schema for the approval page. + self_approval: Whether the user who triggered the flow can approve it (default True). + Example:: urls = await step("urls", lambda: get_resume_urls()) @@ -2778,7 +2785,7 @@ async def wait_for_approval( """ ctx: WorkflowCtx | None = _workflow_ctx.get(None) if ctx is not None: - return await ctx._wait_for_approval(timeout=timeout, form=form) + return await ctx._wait_for_approval(timeout=timeout, form=form, self_approval=self_approval) raise RuntimeError("wait_for_approval can only be called inside a @workflow") diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 0170d7edca..d30a5be3eb 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -632,7 +632,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -1336,12 +1336,17 @@ async def sleep(seconds: int) # # Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index ec776de97e..674c9986b9 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1605,7 +1605,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -2309,12 +2309,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 7163e76a4a..241d438f58 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -648,12 +648,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index f38ba274c1..8d96473313 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -481,7 +481,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 ba40a2d624..b4db20ae80 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -610,7 +610,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 cdd015863a..ecf7fe2103 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -608,7 +608,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 fddae85f6e..563d01ed48 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -614,7 +614,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 4687be55e4..1d52290283 100644 --- a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md @@ -575,7 +575,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 c860ee696c..e6aa3b848c 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -783,12 +783,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 49087a8b60..1aded4a720 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -1577,6 +1577,7 @@ export class WorkflowCtx { _waitForApproval(options?: { timeout?: number; form?: object; + selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { const key = this._allocKey("approval"); @@ -1597,6 +1598,7 @@ export class WorkflowCtx { key, timeout: options?.timeout ?? 1800, form: options?.form, + self_approval_disabled: !(options?.selfApproval ?? true), steps: [], }); } @@ -1842,6 +1844,7 @@ export function workflow(fn: (...args: any[]) => Promise) { export function waitForApproval(options?: { timeout?: number; form?: object; + selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); if (!ctx) { From 6060ac3adc0afd94d62ec233f5d7282238d3ffc9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 21:40:26 +0000 Subject: [PATCH 016/153] chore(main): release 1.664.0 (#8498) * chore(main): release 1.664.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 15 ++ backend/Cargo.lock | 175 +++++++++--------- 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, 113 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8aa1a9648..e08a1cf181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24) + + +### Features + +* add instance-level AI settings ([#8453](https://github.com/windmill-labs/windmill/issues/8453)) ([db5e036](https://github.com/windmill-labs/windmill/commit/db5e03610da325288d53afdbca94b9cbfc7ceace)) +* add selfApproval option to WAC + inline approval buttons ([#8440](https://github.com/windmill-labs/windmill/issues/8440)) ([d578e40](https://github.com/windmill-labs/windmill/commit/d578e40101a838d3dffda14157cf72ee4d5a93c0)) +* flow group nodes with collapsible groups ([#8075](https://github.com/windmill-labs/windmill/issues/8075)) ([81eb446](https://github.com/windmill-labs/windmill/commit/81eb446eee359f44374b81320690e5345fd08c15)) + + +### Bug Fixes + +* add GIT_SSL_CAINFO to tracing proxy env vars ([#8502](https://github.com/windmill-labs/windmill/issues/8502)) ([bdfd5d5](https://github.com/windmill-labs/windmill/commit/bdfd5d57261a4bb760fc57ad41ee56aff9b9c0af)) +* create parent dirs and accept 'python' alias in script bootstrap ([#8497](https://github.com/windmill-labs/windmill/issues/8497)) ([7f27d99](https://github.com/windmill-labs/windmill/commit/7f27d996accb3c3b471d1c50df397867d89c738a)) + ## [1.663.0](https://github.com/windmill-labs/windmill/compare/v1.662.0...v1.663.0) (2026-03-24) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 30138085c9..1871fda815 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -7401,14 +7401,15 @@ dependencies = [ [[package]] name = "ipconfig" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d72a21f6a71a6c4c3160e095e8925861f5119dd26ef71acee1b9146f74f76c8" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ "socket2 0.6.3", "widestring", + "windows-registry", + "windows-result 0.4.1", "windows-sys 0.61.2", - "winreg", ] [[package]] @@ -8063,9 +8064,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.9.4", "libc", @@ -15044,9 +15045,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" [[package]] name = "unicode-width" @@ -15746,7 +15747,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-nats", @@ -15822,7 +15823,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15835,7 +15836,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "argon2", @@ -15976,7 +15977,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15999,7 +16000,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16012,7 +16013,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16038,7 +16039,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.663.0" +version = "1.664.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16048,7 +16049,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16065,7 +16066,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16088,7 +16089,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16111,7 +16112,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16127,7 +16128,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16147,7 +16148,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16167,7 +16168,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16181,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-nats", @@ -16209,7 +16210,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16234,7 +16235,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16252,7 +16253,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16274,7 +16275,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16294,7 +16295,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16324,7 +16325,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16351,7 +16352,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.663.0" +version = "1.664.0" dependencies = [ "lazy_static", "serde", @@ -16363,7 +16364,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.663.0" +version = "1.664.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16386,7 +16387,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16400,7 +16401,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16431,7 +16432,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.663.0" +version = "1.664.0" dependencies = [ "chrono", "lazy_static", @@ -16445,7 +16446,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16464,7 +16465,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.663.0" +version = "1.664.0" dependencies = [ "aes-gcm", "anyhow", @@ -16564,7 +16565,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.663.0" +version = "1.664.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16583,7 +16584,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.663.0" +version = "1.664.0" dependencies = [ "regex", "serde", @@ -16598,7 +16599,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16622,7 +16623,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "futures", @@ -16639,7 +16640,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.663.0" +version = "1.664.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16655,7 +16656,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -16676,7 +16677,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -16707,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-oauth2", @@ -16731,7 +16732,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-stream", @@ -16765,7 +16766,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "futures", @@ -16783,7 +16784,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.663.0" +version = "1.664.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16792,7 +16793,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16804,7 +16805,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde_json", @@ -16816,7 +16817,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "gosyn", @@ -16828,7 +16829,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16840,7 +16841,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde_json", @@ -16852,7 +16853,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "nu-parser", @@ -16863,7 +16864,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16874,7 +16875,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16886,7 +16887,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16897,7 +16898,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-recursion", @@ -16919,7 +16920,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16933,7 +16934,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16950,7 +16951,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16963,7 +16964,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde", @@ -16975,7 +16976,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16993,7 +16994,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17009,7 +17010,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17025,7 +17026,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde", @@ -17036,7 +17037,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-recursion", @@ -17073,7 +17074,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "const_format", @@ -17111,7 +17112,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.663.0" +version = "1.664.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17122,7 +17123,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-recursion", @@ -17151,7 +17152,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17174,7 +17175,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17207,7 +17208,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17227,7 +17228,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17261,7 +17262,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17296,7 +17297,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17319,7 +17320,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17343,7 +17344,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-nats", @@ -17367,7 +17368,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17402,7 +17403,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17430,7 +17431,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17453,7 +17454,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17472,7 +17473,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-once-cell", @@ -17580,7 +17581,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.663.0" +version = "1.664.0" dependencies = [ "bytes", "futures", @@ -18193,16 +18194,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - [[package]] name = "winsafe" version = "0.0.19" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6dc821689c..3b44e3d87a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.663.0" +version = "1.664.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.663.0" +version = "1.664.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 6576633319..993569e0f0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.663.0 + version: 1.664.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index f93b61c6ee..e79e14588d 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.663.0"; +export const VERSION = "v1.664.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 83e9c1bbc8..8901d5f979 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -68,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.663.0"; +export const VERSION = "1.664.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 792e303b6e..3cd78bf5a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.663.0", + "version": "1.664.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.663.0", + "version": "1.664.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 39bb3b6365..19eb2b69b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.663.0", + "version": "1.664.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 130d95a820..13775e4a5d 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.663.0" +wmill = ">=1.664.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 9a0983b663..7480b43a1e 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.663.0 + version: 1.664.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index c84a6d49f7..6629cc8034 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.663.0' + ModuleVersion = '1.664.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 92fbaf56d1..8cfc22d3a6 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.663.0" +version = "1.664.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 5435328c88..9632ec19a2 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.663.0", + "version": "1.664.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 bd66c04c51..d4023c7e69 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.663.0", + "version": "1.664.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index eb4feec596..694b27ca91 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.663.0 +1.664.0 From 85c52e2cded10606cc895d0d3b717e13c69bc9b3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 06:40:20 +0000 Subject: [PATCH 017/153] fix: use /apps_raw/get/ redirect URL for raw apps set as workspace default (#8508) * fix: use /apps_raw/get/ redirect URL for raw apps set as workspace default Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx cache for default_app query Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...c3592deb61d1111d1430ddd2879b72e6424ef.json | 28 +++++++++++++++++++ ...3f3be67b6160cd258c86b8e8f22a6d601afd0.json | 22 --------------- .../windmill-api-workspaces/src/workspaces.rs | 19 +++++++++---- backend/windmill-api/openapi.yaml | 2 ++ frontend/src/lib/components/Login.svelte | 3 +- .../(logged)/user/(user)/login/+page.svelte | 3 +- .../user/(user)/workspaces/+page.svelte | 3 +- 7 files changed, 49 insertions(+), 31 deletions(-) create mode 100644 backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json delete mode 100644 backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json diff --git a/backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json b/backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json new file mode 100644 index 0000000000..0930bdf1b8 --- /dev/null +++ b/backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option\"\n FROM workspace_settings ws\n LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id\n LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]\n WHERE ws.workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "default_app_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "default_app_raw: Option", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef" +} diff --git a/backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json b/backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json deleted file mode 100644 index b7c642ef12..0000000000 --- a/backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT default_app FROM workspace_settings WHERE workspace_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "default_app", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0" -} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 8cd8f27d9b..55ad777cbf 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -2233,22 +2233,29 @@ async fn edit_default_app( #[derive(Serialize)] struct WorkspaceDefaultApp { pub default_app_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_app_raw: Option, } async fn get_default_app( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { - let mut tx = db.begin().await?; - let default_app_path = sqlx::query_scalar!( - "SELECT default_app FROM workspace_settings WHERE workspace_id = $1", + let row = sqlx::query!( + "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option\" + FROM workspace_settings ws + LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id + LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)] + WHERE ws.workspace_id = $1", &w_id ) - .fetch_one(&mut *tx) + .fetch_one(&db) .await .map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?; - tx.commit().await?; - Ok(Json(WorkspaceDefaultApp { default_app_path })) + Ok(Json(WorkspaceDefaultApp { + default_app_path: row.default_app_path, + default_app_raw: row.default_app_raw, + })) } async fn edit_error_handler( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 993569e0f0..bdd70229e9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3702,6 +3702,8 @@ paths: properties: default_app_path: type: string + default_app_raw: + type: boolean /w/{workspace}/workspaces/usage: get: diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 1e375cb53d..c1a2a7ebdb 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -164,7 +164,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + goto(`${prefix}/${defaultApp.default_app_path}`) } else { goto(rd ?? '/') } diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte index 63843578c9..3e7ef57bf6 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte @@ -87,7 +87,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + goto(`${prefix}/${defaultApp.default_app_path}`) } else { goto(rd ?? '/') } diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index 0ddfd92914..2cc2a65fbf 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -151,7 +151,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - await goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + await goto(`${prefix}/${defaultApp.default_app_path}`) } else { if (rd?.startsWith('http')) { window.location.href = rd From 1341a1321da3ab7c5ce24df27fe6b028887d6a0b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:11:57 +0000 Subject: [PATCH 018/153] chore: update tantivy from 0.24 to 0.26 (#8510) * [ee] chore: update tantivy from 0.24 to 0.26 - Rebase windmill-labs/tantivy fork onto upstream 0.26 - Bump serde pin from 1.0.219 to 1.0.220 (required by tantivy 0.26's time dependency) Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to ec613f2db9e72e32e9131181546dcd679405a782 This commit updates the EE repository reference after PR #479 was merged in windmill-ee-private. Previous ee-repo-ref: 920cf601b0651b7ba94493668ea051e00f3e74bf New ee-repo-ref: ec613f2db9e72e32e9131181546dcd679405a782 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/Cargo.lock | 149 +++++++++++++++++++++++----------------- backend/Cargo.toml | 4 +- backend/ee-repo-ref.txt | 2 +- 3 files changed, 89 insertions(+), 66 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1871fda815..fcb56bb81c 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -405,7 +405,7 @@ dependencies = [ "arrow-data", "arrow-schema", "flatbuffers", - "lz4_flex", + "lz4_flex 0.11.6", ] [[package]] @@ -2124,7 +2124,7 @@ dependencies = [ "num-traits", "num_cpus", "rand 0.9.0", - "rand_distr 0.5.1", + "rand_distr", "rayon", "safetensors", "thiserror 2.0.18", @@ -3640,6 +3640,12 @@ dependencies = [ "sqlparser 0.55.0", ] +[[package]] +name = "datasketches" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" + [[package]] name = "debug-helper" version = "0.3.13" @@ -5678,7 +5684,7 @@ dependencies = [ "half", "num-traits", "rand 0.9.0", - "rand_distr 0.5.1", + "rand_distr", ] [[package]] @@ -6490,7 +6496,7 @@ dependencies = [ "crunchy", "num-traits", "rand 0.9.0", - "rand_distr 0.5.1", + "rand_distr", "zerocopy", ] @@ -7109,15 +7115,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyperloglogplus" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3" -dependencies = [ - "serde", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -8197,6 +8194,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.0", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -8221,6 +8227,12 @@ dependencies = [ "twox-hash 2.1.2", ] +[[package]] +name = "lz4_flex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" + [[package]] name = "lzma-sys" version = "0.1.20" @@ -9186,9 +9198,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-format" @@ -9713,6 +9725,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + [[package]] name = "os_pipe" version = "1.1.5" @@ -9738,7 +9759,7 @@ checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "ownedbytes" version = "0.9.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "stable_deref_trait", ] @@ -9850,7 +9871,7 @@ dependencies = [ "futures", "half", "hashbrown 0.15.5", - "lz4_flex", + "lz4_flex 0.11.6", "num", "num-bigint", "object_store", @@ -10842,16 +10863,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "rand_distr" version = "0.5.1" @@ -12191,10 +12202,11 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.220" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "ceecad4c782e936ac90ecfd6b56532322e3262b14320abf30ce89a92ffdbfe22" dependencies = [ + "serde_core", "serde_derive", ] @@ -12216,7 +12228,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" dependencies = [ - "ordered-float", + "ordered-float 2.10.1", "serde", ] @@ -12241,10 +12253,19 @@ dependencies = [ ] [[package]] -name = "serde_derive" -version = "1.0.219" +name = "serde_core" +version = "1.0.220" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "ddba47394f3b862d6ff6efdbd26ca4673e3566a307880a0ffb98f274bbe0ec32" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.220" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e1f3b1761e96def5ec6d04a6e7421c0404fa3cf5c0155f1e2848fae3d8cc08" dependencies = [ "proc-macro2", "quote", @@ -12608,9 +12629,9 @@ checksum = "1b6709c7b6754dca1311b3c73e79fcce40dd414c782c66d88e8823030093b02b" [[package]] name = "sketches-ddsketch" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" +checksum = "05e40b6cf54d988dc1a2223531b969c9a9e30906ad90ef64890c27b4bfbb46ea" dependencies = [ "serde", ] @@ -13705,8 +13726,8 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tantivy" -version = "0.24.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.26.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "aho-corasick", "arc-swap", @@ -13717,17 +13738,17 @@ dependencies = [ "census", "crc32fast", "crossbeam-channel", + "datasketches", "downcast-rs", "fastdivide", "fnv", "fs4", "htmlescape", - "hyperloglogplus", "itertools 0.14.0", "levenshtein_automata", "log", - "lru 0.12.5", - "lz4_flex", + "lru 0.16.3", + "lz4_flex 0.13.0", "measure_time", "memmap2 0.9.10", "once_cell", @@ -13750,22 +13771,23 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "time", + "typetag", "uuid", "winapi", ] [[package]] name = "tantivy-bitpacker" -version = "0.8.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.9.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "bitpacking", ] [[package]] name = "tantivy-columnar" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "downcast-rs", "fastdivide", @@ -13779,8 +13801,8 @@ dependencies = [ [[package]] name = "tantivy-common" -version = "0.9.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.10.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "async-trait", "byteorder", @@ -13802,18 +13824,20 @@ dependencies = [ [[package]] name = "tantivy-query-grammar" -version = "0.24.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.25.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ + "fnv", "nom 7.1.3", + "ordered-float 5.1.0", "serde", "serde_json", ] [[package]] name = "tantivy-sstable" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "futures-util", "itertools 0.14.0", @@ -13825,18 +13849,17 @@ dependencies = [ [[package]] name = "tantivy-stacker" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "murmurhash32", - "rand_distr 0.4.3", "tantivy-common", ] [[package]] name = "tantivy-tokenizer-api" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "serde", ] @@ -13966,7 +13989,7 @@ checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" dependencies = [ "byteorder", "integer-encoding", - "ordered-float", + "ordered-float 2.10.1", ] [[package]] @@ -14043,30 +14066,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3b44e3d87a..927a6f6ba3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -373,7 +373,7 @@ tower = "^0" tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] } tower-cookies = "^0.10" #stuck because of swc for now -serde = "=1.0.219" +serde = "=1.0.220" serde_json = { version = "^1", features = ["preserve_order", "raw_value"] } serde_yml = "0.0.12" uuid = { version = "^1", features = ["serde", "v4", "js"] } @@ -587,7 +587,7 @@ tikv-jemalloc-ctl = { version = "^0.5" } triomphe = "^0" pin-project-lite = "^0" -tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6a24621231202ccd77bec90d8787e2281fb94e4e" } +tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" } backon = "1.3.0" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 7ef2ef46db..c5ca6a15cf 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -faeaa43bbe2ba4804f80b828b85fd4d6daef096c +ec613f2db9e72e32e9131181546dcd679405a782 From fe223bffa32c17815988ff4210d89f4f01d486e2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:34:24 +0000 Subject: [PATCH 019/153] chore: update samael from 0.0.14 to 0.0.20 (#8512) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 105 +++++++++++++++++++-------------------------- backend/Cargo.toml | 2 +- 2 files changed, 46 insertions(+), 61 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index fcb56bb81c..af95c7a85e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1594,29 +1594,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.9.4", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.117", - "which 4.4.2", -] - [[package]] name = "bindgen" version = "0.70.1" @@ -1667,6 +1644,8 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.13.0", + "log", + "prettyplease", "proc-macro2", "quote", "regex", @@ -4862,7 +4841,16 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" dependencies = [ - "derive_builder_macro", + "derive_builder_macro 0.12.0", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro 0.20.2", ] [[package]] @@ -4877,16 +4865,38 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder_macro" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" dependencies = [ - "derive_builder_core", + "derive_builder_core 0.12.0", "syn 1.0.109", ] +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core 0.20.2", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -7476,15 +7486,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -7900,12 +7901,6 @@ dependencies = [ "spin 0.9.8", ] -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -9349,7 +9344,7 @@ dependencies = [ "md-5 0.10.6", "parking_lot", "percent-encoding", - "quick-xml 0.37.5", + "quick-xml", "rand 0.9.0", "reqwest 0.12.28", "ring 0.17.14", @@ -10646,16 +10641,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "quick-xml" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.37.5" @@ -11950,15 +11935,15 @@ dependencies = [ [[package]] name = "samael" -version = "0.0.14" +version = "0.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75583aad4a51c50fc0af69c230d18078c9d5a69a98d0f6013d01053acf744f4" +checksum = "8b010d88b2c7b2c3fc9e49f6fffa086d4c350ec50538a8082f88e446ea16c670" dependencies = [ - "base64 0.21.7", - "bindgen 0.69.5", + "base64 0.22.1", + "bindgen 0.72.1", "chrono", "data-encoding", - "derive_builder", + "derive_builder 0.20.2", "flate2", "lazy_static", "libc", @@ -11967,10 +11952,10 @@ dependencies = [ "openssl-probe 0.1.6", "openssl-sys", "pkg-config", - "quick-xml 0.30.0", - "rand 0.8.5", + "quick-xml", + "rand 0.9.0", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", "url", "uuid", ] @@ -14152,7 +14137,7 @@ checksum = "d9be88c795d8b9f9c4002b3a8f26a6d0876103a6f523b32ea3bac52d8560c17c" dependencies = [ "aho-corasick", "clap", - "derive_builder", + "derive_builder 0.12.0", "esaxx-rs", "getrandom 0.2.17", "indicatif", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 927a6f6ba3..e98d4b793e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -510,7 +510,7 @@ native-tls = ">=0.2, <0.2.17" # samael will break compilation on MacOS. Use this fork instead to make it work # samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] } libxml = { version = "=0.3.3" } -samael = { version="0.0.14", features = ["xmlsec"] } +samael = { version="0.0.20", features = ["xmlsec"] } gcp_auth = "0.9.0" rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]} jsonwebtoken = "8.3.0" From 0db21aa6b7c5b557ad53a1a74493c4fd80a53b49 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:44:49 +0000 Subject: [PATCH 020/153] samael bump --- backend/Cargo.lock | 3 +-- backend/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index af95c7a85e..a5e2e8fc98 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -11936,8 +11936,7 @@ dependencies = [ [[package]] name = "samael" version = "0.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b010d88b2c7b2c3fc9e49f6fffa086d4c350ec50538a8082f88e446ea16c670" +source = "git+https://github.com/njaremko/samael?rev=f879f1942ec1b34b6d3027ce7e4724ad95d15dfa#f879f1942ec1b34b6d3027ce7e4724ad95d15dfa" dependencies = [ "base64 0.22.1", "bindgen 0.72.1", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e98d4b793e..e1d6dbe9d2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -510,7 +510,7 @@ native-tls = ">=0.2, <0.2.17" # samael will break compilation on MacOS. Use this fork instead to make it work # samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] } libxml = { version = "=0.3.3" } -samael = { version="0.0.20", features = ["xmlsec"] } +samael = { git="https://github.com/njaremko/samael", rev="f879f1942ec1b34b6d3027ce7e4724ad95d15dfa", features = ["xmlsec"] } gcp_auth = "0.9.0" rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]} jsonwebtoken = "8.3.0" From e3620e074e1bdb46b2b8d732f35a91d300589663 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:56:45 +0000 Subject: [PATCH 021/153] fix: serve index disk storage sizes from /srch/ endpoint (#8511) * [ee] fix: serve index disk storage sizes from /srch/ endpoint On multi-container deployments, the API server doesn't have the index files on its local disk, so disk size was always reported as 0.0B. Added a new GET /srch/index/storage/disk endpoint that calculates disk sizes on the indexer process (which owns the files). The frontend now fetches disk sizes from this endpoint in parallel with the status call. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 71aab648925f31cde37efd31d79a7f3a977fd42a This commit updates the EE repository reference after PR #480 was merged in windmill-ee-private. Previous ee-repo-ref: b3e0000e2528809302c18f36930aebf3d004747a New ee-repo-ref: 71aab648925f31cde37efd31d79a7f3a977fd42a Automated by sync-ee-ref workflow. * chore: update ee-repo-ref to indexer-disk-storage-zero branch Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx metadata and ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...3e69e4ef8821c6cbf3b4f296b3853d95692af.json | 22 +++++++++ ...ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json | 28 ----------- ...08cb1ca21fbdba3373af54fadf1f4af324073.json | 35 -------------- ...59f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json | 46 ------------------- ...6e8a4f8f3a9bf04238b33e9caf46836df73d9.json | 35 -------------- ...91688f3ed0efd3a43e81f4ea296255248092c.json | 16 ------- ...32e97ebefb46be9e58bd3da9067748075311b.json | 35 -------------- ...8903bec93ef79a71053c00227e17c6f0415a2.json | 23 ---------- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi.yaml | 21 +++++++++ .../IndexerMemorySettings.svelte | 29 ++++++++---- 11 files changed, 64 insertions(+), 228 deletions(-) create mode 100644 backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json delete mode 100644 backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json delete mode 100644 backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json delete mode 100644 backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json delete mode 100644 backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json delete mode 100644 backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json delete mode 100644 backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json delete mode 100644 backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json diff --git a/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json new file mode 100644 index 0000000000..a78e67067f --- /dev/null +++ b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af" +} diff --git a/backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json b/backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json deleted file mode 100644 index 0a2976f868..0000000000 --- a/backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "parent_job", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "flow_step_id", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - true, - true - ] - }, - "hash": "32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9" -} diff --git a/backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json b/backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json deleted file mode 100644 index f990932367..0000000000 --- a/backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "debounced_times", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "first_started_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "job_id_to_debounce", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073" -} diff --git a/backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json b/backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json deleted file mode 100644 index 9ec97dbc82..0000000000 --- a/backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH RECURSIVE chain AS (\n SELECT\n j.id,\n j.parent_job,\n j.flow_step_id,\n 1 AS depth\n FROM v2_job j\n WHERE j.id = $1\n UNION ALL\n SELECT\n pj.id,\n pj.parent_job,\n pj.flow_step_id,\n c.depth + 1\n FROM chain c\n JOIN v2_job pj ON pj.id = c.parent_job\n WHERE c.parent_job IS NOT NULL\n )\n SELECT\n c.id,\n c.parent_job,\n c.flow_step_id,\n EXISTS(SELECT 1 FROM v2_job_queue q WHERE q.id = c.parent_job) AS \"parent_in_queue!\",\n EXISTS(\n SELECT 1 FROM v2_job sib\n WHERE sib.parent_job = c.parent_job\n AND sib.id != c.id\n AND sib.id IN (SELECT sq.id FROM v2_job_queue sq)\n ) AS \"has_other_active_siblings!\"\n FROM chain c\n WHERE c.depth >= 1\n ORDER BY c.depth ASC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "parent_job", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "flow_step_id", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "parent_in_queue!", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "has_other_active_siblings!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null, - null, - null, - null, - null - ] - }, - "hash": "950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb" -} diff --git a/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json b/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json deleted file mode 100644 index 7dd6e9ac5d..0000000000 --- a/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "debounced_times", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "first_started_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "job_id_to_debounce", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9" -} diff --git a/backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json b/backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json deleted file mode 100644 index 5fb12bed16..0000000000 --- a/backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status SET flow_status = (\n SELECT jsonb_set(\n flow_status,\n ARRAY['modules', (idx - 1)::text],\n $2::jsonb\n )\n FROM jsonb_array_elements(flow_status->'modules')\n WITH ORDINALITY arr(elem, idx)\n WHERE elem->>'id' = $3\n LIMIT 1\n ) WHERE id = $1 AND (\n SELECT COUNT(*) FROM jsonb_array_elements(flow_status->'modules')\n WITH ORDINALITY arr(elem, idx)\n WHERE elem->>'id' = $3\n ) > 0", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Jsonb", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c" -} diff --git a/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json b/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json deleted file mode 100644 index 7d7842d7f4..0000000000 --- a/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "debounced_times", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "first_started_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "job_id_to_debounce", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b" -} diff --git a/backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json b/backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json deleted file mode 100644 index c43a2bcd30..0000000000 --- a/backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(\n SELECT 1 FROM v2_job\n WHERE parent_job = $1 AND id != $2\n AND id IN (SELECT id FROM v2_job_queue)\n ) as has", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "has", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index c5ca6a15cf..40b16c0b62 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ec613f2db9e72e32e9131181546dcd679405a782 +414202845a45e2a7c6a2d3e154bd8dfd0273cc14 \ No newline at end of file diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index bdd70229e9..5f350f2ce5 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -17429,6 +17429,27 @@ paths: description: count of log lines that matched the query per hostname type: object + /srch/index/storage/disk: + get: + summary: Get index disk storage sizes from the indexer. + operationId: getIndexDiskStorageSizes + tags: + - indexSearch + responses: + "200": + description: disk storage sizes for each index + content: + application/json: + schema: + type: object + properties: + job_index_disk_size_bytes: + type: integer + nullable: true + log_index_disk_size_bytes: + type: integer + nullable: true + /indexer/delete/{idx_name}: delete: summary: Clear an index and restart the indexer. diff --git a/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte b/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte index 52bc3cd9bc..eb37ac8a0c 100644 --- a/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte +++ b/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte @@ -2,7 +2,7 @@ import { Button } from '$lib/components/common' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { IndexSearchService } from '$lib/gen' - import type { GetIndexerStatusResponse } from '$lib/gen' + import type { GetIndexerStatusResponse, GetIndexDiskStorageSizesResponse } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { displaySize } from '$lib/utils' import Tooltip from '../Tooltip.svelte' @@ -24,6 +24,7 @@ let clearServiceLogsIndexModalOpen = $state(false) let status: GetIndexerStatusResponse | undefined = $state(undefined) + let diskSizes: GetIndexDiskStorageSizesResponse | undefined = $state(undefined) let statusLoading = $state(true) let statusError = $state(false) @@ -41,9 +42,15 @@ statusLoading = true statusError = false try { - status = await IndexSearchService.getIndexerStatus() + const [statusRes, diskRes] = await Promise.all([ + IndexSearchService.getIndexerStatus(), + IndexSearchService.getIndexDiskStorageSizes().catch(() => undefined) + ]) + status = statusRes + diskSizes = diskRes } catch (e) { status = undefined + diskSizes = undefined statusError = true } finally { statusLoading = false @@ -139,7 +146,11 @@ : 'bg-red-500'}" > {label}: - + {entry?.is_alive ? 'Running' : 'Stopped'} {#if entry?.last_locked_at} @@ -161,21 +172,21 @@
    Jobs index: - {#if status.job_indexer?.storage?.disk_size_bytes != null} - Disk: {displaySize(status.job_indexer.storage.disk_size_bytes) ?? 'N/A'} + {#if diskSizes?.job_index_disk_size_bytes != null} + Disk: {displaySize(diskSizes.job_index_disk_size_bytes) ?? 'N/A'} {/if} {#if status.job_indexer?.storage?.s3_size_bytes != null} - {#if status.job_indexer?.storage?.disk_size_bytes != null}·{/if} + {#if diskSizes?.job_index_disk_size_bytes != null}·{/if} S3: {displaySize(status.job_indexer.storage.s3_size_bytes) ?? 'N/A'} {/if} Service logs index: - {#if status.log_indexer?.storage?.disk_size_bytes != null} - Disk: {displaySize(status.log_indexer.storage.disk_size_bytes) ?? 'N/A'} + {#if diskSizes?.log_index_disk_size_bytes != null} + Disk: {displaySize(diskSizes.log_index_disk_size_bytes) ?? 'N/A'} {/if} {#if status.log_indexer?.storage?.s3_size_bytes != null} - {#if status.log_indexer?.storage?.disk_size_bytes != null}·{/if} + {#if diskSizes?.log_index_disk_size_bytes != null}·{/if} S3: {displaySize(status.log_indexer.storage.s3_size_bytes) ?? 'N/A'} {/if} From 79d2bd51a00654162754046308d7670242120df6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 08:41:29 +0000 Subject: [PATCH 022/153] feat: move basic git sync from EE to CE with runtime user count gating (#8493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: move basic git sync from EE to CE with runtime user count gating Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt for git sync CE migration Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: keep git sync impl in private repo, revert oss to stub Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt after merge Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use LICENSE_KEY check instead of get_license_plan for runtime gating Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * fix: improve git sync CE UX — use "Community Edition" wording, mention user limit Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use "workspace members" instead of "users" in git sync messaging Co-Authored-By: Claude Opus 4.6 (1M context) * fix: lower CE git sync limit from 3 to 2 workspace members Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * fix: simplify git sync CE alerts to warn about EE feature with member limit Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add EE feature restrictions detail to CE git sync warning Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show git sync settings even when >2 members, with disabled warning Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show error alert when git sync settings exist but members exceed CE limit Co-Authored-By: Claude Opus 4.6 (1M context) * fix: mention CE git sync limit is for testing and hobbyist use Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 79eeacccc0438010d7dfa60207a5cbdaf2eda08d This commit updates the EE repository reference after PR #476 was merged in windmill-ee-private. Previous ee-repo-ref: c4d69c6e700c16d44f909d9c7b6738b07043db98 New ee-repo-ref: 79eeacccc0438010d7dfa60207a5cbdaf2eda08d Automated by sync-ee-ref workflow. * chore: update sqlx cache Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate full sqlx cache after main merge Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx cache Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref and regenerate sqlx cache with private feature Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use LICENSE_KEY_VALID for EE check, allow delete without access check, extract helpers Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: use compile-time cfg(enterprise) gating instead of runtime license checks Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 6171a91da38d6d16a88aeb1a3a4f4df78f995383 This commit updates the EE repository reference after PR #481 was merged in windmill-ee-private. Previous ee-repo-ref: 52681940cda6d70f65aeeb7144288f060b4d736e New ee-repo-ref: 6171a91da38d6d16a88aeb1a3a4f4df78f995383 Automated by sync-ee-ref workflow. * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc This commit updates the EE repository reference after PR #482 was merged in windmill-ee-private. Previous ee-repo-ref: 6e5b2741831468a7b30b26c0df1241e6141c6833 New ee-repo-ref: b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc Automated by sync-ee-ref workflow. * fix: gate CE_GIT_SYNC_MAX_USERS behind cfg(not(enterprise)) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...aacf6af2c284ae446860113c82bc4e1da08ab.json | 12 - ...bc47caebc25215a430d6b301b35e265888159.json | 12 - ...fb7cf5f2b76f013c274245af13d7d727ebf1f.json | 12 - ...e2e60e3183fa81a411622891caea6dc03fa90.json | 15 -- ...960ffc33da5f31bf780e8fd6a66d5150b8027.json | 12 - ...69c87a9d29370ec985d2c8c28633cd078ffaf.json | 12 - ...74da8c73120b3e16194904575f79a4e055002.json | 12 - ...437ab3e02d8c3c10c53decc664533b8d04bc0.json | 22 -- backend/ee-repo-ref.txt | 2 +- .../windmill-api-workspaces/src/workspaces.rs | 131 +++++++--- backend/windmill-api/openapi.yaml | 31 +++ backend/windmill-common/src/ee_oss.rs | 1 + backend/windmill-git-sync/Cargo.toml | 2 +- backend/windmill-git-sync/src/lib.rs | 20 +- cli/package-lock.json | 14 +- .../git_sync/GitSyncContext.svelte.ts | 11 + .../components/git_sync/GitSyncSection.svelte | 245 +++++++++++------- 17 files changed, 298 insertions(+), 268 deletions(-) delete mode 100644 backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json delete mode 100644 backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json delete mode 100644 backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json delete mode 100644 backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json delete mode 100644 backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json delete mode 100644 backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json delete mode 100644 backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json delete mode 100644 backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json diff --git a/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json b/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json deleted file mode 100644 index 0ad1fe4367..0000000000 --- a/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app_version (id, app_id, value, created_by, created_at)\n VALUES (3001, 3001, '{\"grid\": []}', 'admin', NOW())", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab" -} diff --git a/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json b/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json deleted file mode 100644 index 24d3c8929a..0000000000 --- a/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159" -} diff --git a/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json b/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json deleted file mode 100644 index 10cab9117a..0000000000 --- a/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms)\n VALUES ('test-workspace', 'u/operator/existing_flow', 'Existing flow', '', '{\"modules\": []}', 'admin', NOW(), '{}', '{}')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f" -} diff --git a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json deleted file mode 100644 index 27d46b27ed..0000000000 --- a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json b/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json deleted file mode 100644 index 8e558fe67b..0000000000 --- a/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms)\n VALUES (3001, 'test-workspace', 'u/operator/existing_app', 'Existing app', '{}',\n '{\"on_behalf_of\": \"u/admin\", \"on_behalf_of_email\": \"admin@windmill.dev\", \"execution_mode\": \"viewer\"}', '{}')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027" -} diff --git a/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json b/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json deleted file mode 100644 index 6da123cbc4..0000000000 --- a/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'editors', 'charlie')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf" -} diff --git a/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json b/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json deleted file mode 100644 index d7cc49fe3f..0000000000 --- a/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms)\n VALUES ('test-workspace', 3001, 'u/operator/existing_script', 'export function main() { return \"original\"; }', 'deno', 'script', 'admin', '{}', 'Existing script', '', '', '{}')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002" -} diff --git a/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json b/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json deleted file mode 100644 index d9b7688eba..0000000000 --- a/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id,\n script_path, is_flow, workspace_id, edited_by, permissioned_as\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "VarcharArray", - "Varchar", - "Varchar", - "Bool", - "Varchar", - "Varchar", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 40b16c0b62..7d86a6114e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -414202845a45e2a7c6a2d3e154bd8dfd0273cc14 \ No newline at end of file +b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 55ad777cbf..fb234a13a3 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -35,7 +35,6 @@ use windmill_common::variables::{ build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE, }; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; -#[cfg(feature = "enterprise")] use windmill_common::workspaces::GitRepositorySettings; #[cfg(feature = "enterprise")] use windmill_common::workspaces::WorkspaceDeploymentUISettings; @@ -115,6 +114,7 @@ pub fn workspaced_service() -> Router { .route("/list_datatables", get(list_datatables)) .route("/list_datatable_schemas", get(list_datatable_schemas)) .route("/edit_datatable_config", post(edit_datatable_config)) + .route("/git_sync_enabled", get(get_git_sync_enabled)) .route("/edit_git_sync_config", post(edit_git_sync_config)) .route("/edit_git_sync_repository", post(edit_git_sync_repository)) .route( @@ -1595,24 +1595,20 @@ async fn edit_datatable_config( #[derive(Deserialize)] pub struct EditGitSyncConfig { - #[cfg(feature = "enterprise")] pub git_sync_settings: Option, } -#[cfg(feature = "enterprise")] #[derive(Deserialize, Debug)] pub struct EditGitSyncRepository { pub git_repo_resource_path: String, pub repository: GitRepositorySettings, } -#[cfg(feature = "enterprise")] #[derive(Deserialize, Debug)] pub struct DeleteGitSyncRepositoryRequest { pub git_repo_resource_path: String, } -#[cfg(feature = "enterprise")] fn validate_git_repo_resource_path(path: &str) -> Result<()> { // Resource paths should follow the pattern: $res:f// or $res:u// if path.is_empty() { @@ -1661,7 +1657,6 @@ fn validate_git_repo_resource_path(path: &str) -> Result<()> { Ok(()) } -#[cfg(feature = "enterprise")] fn cleanup_legacy_git_sync_settings_in_memory( git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings, workspace_id: &str, @@ -1688,18 +1683,72 @@ fn cleanup_legacy_git_sync_settings_in_memory( } #[cfg(not(feature = "enterprise"))] -async fn edit_git_sync_config( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_new_config): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); +const CE_GIT_SYNC_MAX_USERS: i64 = 2; + +#[cfg(feature = "enterprise")] +async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> { + Ok(()) +} + +#[cfg(not(feature = "enterprise"))] +async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> { + let user_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + w_id + ) + .fetch_one(db) + .await? + .unwrap_or(0); + + if user_count > CE_GIT_SYNC_MAX_USERS { + return Err(Error::BadRequest(format!( + "Git sync is available for workspaces with up to {} members. \ + Upgrade to Windmill Enterprise Edition for unlimited workspace members.", + CE_GIT_SYNC_MAX_USERS + ))); + } + Ok(()) } #[cfg(feature = "enterprise")] +async fn get_git_sync_enabled( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, +) -> JsonResult { + Ok(Json(serde_json::json!({ + "enabled": true, + "reason": "enterprise", + "max_repos": null, + "user_count": null, + "max_users": null, + }))) +} + +#[cfg(not(feature = "enterprise"))] +async fn get_git_sync_enabled( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + let user_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + &w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(0); + + let enabled = user_count <= CE_GIT_SYNC_MAX_USERS; + Ok(Json(serde_json::json!({ + "enabled": enabled, + "reason": if enabled { Some("free_tier") } else { None::<&str> }, + "max_repos": if enabled { Some(1) } else { None:: }, + "user_count": user_count, + "max_users": CE_GIT_SYNC_MAX_USERS, + }))) +} + async fn edit_git_sync_config( authed: ApiAuthed, Extension(db): Extension, @@ -1708,6 +1757,7 @@ async fn edit_git_sync_config( Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; + check_git_sync_access(&db, &w_id).await?; let mut tx = db.begin().await?; @@ -1764,19 +1814,6 @@ async fn edit_git_sync_config( Ok(format!("Edit git sync config for workspace {}", &w_id)) } -#[cfg(not(feature = "enterprise"))] -async fn edit_git_sync_repository( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_new_config): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); -} - -#[cfg(feature = "enterprise")] async fn edit_git_sync_repository( authed: ApiAuthed, Extension(db): Extension, @@ -1785,10 +1822,19 @@ async fn edit_git_sync_repository( Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; + check_git_sync_access(&db, &w_id).await?; // Validate the resource path format validate_git_repo_resource_path(&new_config.git_repo_resource_path)?; + // Promotion mode: EE only + #[cfg(not(feature = "enterprise"))] + if new_config.repository.use_individual_branch.unwrap_or(false) { + return Err(Error::BadRequest( + "Promotion mode is an Enterprise Edition feature".to_string(), + )); + } + let mut tx = db.begin().await?; // First, get the current git sync settings @@ -1810,6 +1856,20 @@ async fn edit_git_sync_repository( WorkspaceGitSyncSettings::default() }; + // Multi-repo: EE only + #[cfg(not(feature = "enterprise"))] + { + let is_new = !git_sync_settings + .repositories + .iter() + .any(|r| r.git_repo_resource_path == new_config.git_repo_resource_path); + if is_new && !git_sync_settings.repositories.is_empty() { + return Err(Error::BadRequest( + "Multiple git sync repositories is an Enterprise Edition feature".to_string(), + )); + } + } + // Audit log before we move the repository audit_log( &mut *tx, @@ -1893,19 +1953,6 @@ async fn edit_git_sync_repository( )) } -#[cfg(not(feature = "enterprise"))] -async fn delete_git_sync_repository( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_request): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); -} - -#[cfg(feature = "enterprise")] async fn delete_git_sync_repository( authed: ApiAuthed, Extension(db): Extension, @@ -1915,7 +1962,7 @@ async fn delete_git_sync_repository( ) -> Result { require_admin(is_admin, &username)?; - // For deletion, only validate that path is not empty to allow cleanup of malformed entries + // No check_git_sync_access here — admins should always be able to delete/clean up repos if request.git_repo_resource_path.is_empty() { return Err(Error::BadRequest( "Resource path cannot be empty".to_string(), diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5f350f2ce5..a8f83c368a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3430,6 +3430,37 @@ paths: application/json: schema: {} + /w/{workspace}/workspaces/git_sync_enabled: + get: + summary: Check if git sync is available for this workspace + operationId: getGitSyncEnabled + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: Git sync availability status + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + reason: + type: string + nullable: true + max_repos: + type: integer + nullable: true + user_count: + type: integer + nullable: true + max_users: + type: integer + nullable: true + /w/{workspace}/workspaces/edit_git_sync_config: post: summary: edit workspace git sync settings diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index 51b1efd2e2..93d0061ade 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -23,6 +23,7 @@ lazy_static::lazy_static! { } #[cfg(not(feature = "private"))] +#[derive(PartialEq, Eq)] pub enum LicensePlan { Community, Pro, diff --git a/backend/windmill-git-sync/Cargo.toml b/backend/windmill-git-sync/Cargo.toml index 063e1ce54a..148746dcca 100644 --- a/backend/windmill-git-sync/Cargo.toml +++ b/backend/windmill-git-sync/Cargo.toml @@ -9,7 +9,7 @@ name = "windmill_git_sync" path = "./src/lib.rs" [features] -private = [] +private = ["windmill-common/private"] enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"] all_sqlx_features = ["enterprise"] default = [] diff --git a/backend/windmill-git-sync/src/lib.rs b/backend/windmill-git-sync/src/lib.rs index f9cecd46ce..dcbcd5bcb2 100644 --- a/backend/windmill-git-sync/src/lib.rs +++ b/backend/windmill-git-sync/src/lib.rs @@ -272,7 +272,10 @@ mod tests { path: "f/folder/script".to_string(), parent_path: Some("f/folder/old_script".to_string()), }; - assert_eq!(obj.get_parent_path(), Some("f/folder/old_script".to_string())); + assert_eq!( + obj.get_parent_path(), + Some("f/folder/old_script".to_string()) + ); } #[test] @@ -313,21 +316,13 @@ mod tests { #[test] fn test_get_kind_flow() { - let obj = DeployedObject::Flow { - path: "test".to_string(), - parent_path: None, - version: 1, - }; + let obj = DeployedObject::Flow { path: "test".to_string(), parent_path: None, version: 1 }; assert_eq!(obj.get_kind(), "flow"); } #[test] fn test_get_kind_app() { - let obj = DeployedObject::App { - path: "test".to_string(), - version: 1, - parent_path: None, - }; + let obj = DeployedObject::App { path: "test".to_string(), version: 1, parent_path: None }; assert_eq!(obj.get_kind(), "app"); } @@ -346,7 +341,8 @@ mod tests { "http_trigger" ); assert_eq!( - DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None }.get_kind(), + DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None } + .get_kind(), "websocket_trigger" ); assert_eq!( diff --git a/cli/package-lock.json b/cli/package-lock.json index 0e86b9d2b7..ae46c240c9 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -25,10 +25,11 @@ "windmill-parser-wasm-nu": "*", "windmill-parser-wasm-php": "*", "windmill-parser-wasm-py": "*", + "windmill-parser-wasm-py-imports": "*", "windmill-parser-wasm-regex": "*", "windmill-parser-wasm-ruby": "*", "windmill-parser-wasm-rust": "*", - "windmill-parser-wasm-ts": "*", + "windmill-parser-wasm-ts": "^1.659.1", "windmill-parser-wasm-yaml": "*", "windmill-yaml-validator": "1.1.1", "ws": "8.18.0", @@ -1414,6 +1415,11 @@ "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.628.3.tgz", "integrity": "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw==" }, + "node_modules/windmill-parser-wasm-py-imports": { + "version": "1.659.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py-imports/-/windmill-parser-wasm-py-imports-1.659.1.tgz", + "integrity": "sha512-nfnf04WBRf8f/mNIwdvggYOgz3erxrFGjKqULYBH+bKFMlKA6V7eB19m6CXOBkq9rjTp0ZFG+rgsR+Us7JEkyQ==" + }, "node_modules/windmill-parser-wasm-regex": { "version": "1.639.0", "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz", @@ -1430,9 +1436,9 @@ "integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ==" }, "node_modules/windmill-parser-wasm-ts": { - "version": "1.647.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.647.1.tgz", - "integrity": "sha512-64iSAUMU5W/WtePqE1vtDvglDqtkiZVndyieYBVDX0nl7UuovS+wPgH/P3TEoKbR+FwAPacki0CX3DsEzZ/Yxw==" + "version": "1.659.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.659.1.tgz", + "integrity": "sha512-EmXMzOmazC5r29UZh+1TVF9g/N2X51pqK11qDL6xWGeWTIIonhfOZ5nWdGvKQMDUR650fGxehImZzW2v9hNy+w==" }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.593.0", diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index 834982c3f3..ba919fa16b 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -1,4 +1,7 @@ import { getContext, setContext } from 'svelte' +import { enterpriseLicense } from '$lib/stores' +import { get } from 'svelte/store' +import { sendUserToast } from '$lib/toast' import { JobService, WorkspaceService, ResourceService } from '$lib/gen' import type { GitRepositorySettings as BackendGitRepositorySettings, @@ -646,6 +649,10 @@ export function createGitSyncContext(workspace: string) { } function addSyncRepository() { + if (!get(enterpriseLicense) && repositories && repositories.length >= 1) { + sendUserToast('Multiple repositories requires Enterprise Edition', true) + return + } repositories.push({ git_repo_resource_path: '', script_path: undefined, @@ -669,6 +676,10 @@ export function createGitSyncContext(workspace: string) { } function addPromotionRepository() { + if (!get(enterpriseLicense)) { + sendUserToast('Promotion mode requires Enterprise Edition', true) + return + } repositories.push({ git_repo_resource_path: '', script_path: undefined, diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte index 893e782b19..9a03e43b27 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSection.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -6,12 +6,46 @@ import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte' import GitSyncModalManager from './GitSyncModalManager.svelte' import { enterpriseLicense, workspaceStore } from '$lib/stores' + import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { untrack } from 'svelte' // Create context reactively based on workspaceStore const gitSyncContext = $derived($workspaceStore ? setGitSyncContext($workspaceStore) : null) + // Fetch git sync eligibility + let gitSyncStatus = $state<{ + enabled: boolean + reason: string | null + max_repos: number | null + user_count: number | null + max_users: number | null + }>({ enabled: false, reason: null, max_repos: null, user_count: null, max_users: null }) + + $effect(() => { + if ($workspaceStore) { + WorkspaceService.getGitSyncEnabled({ workspace: $workspaceStore }) + .then((status) => { + gitSyncStatus = status as typeof gitSyncStatus + }) + .catch(() => { + gitSyncStatus = { + enabled: false, + reason: null, + max_repos: null, + user_count: null, + max_users: null + } + }) + } + }) + + const gitSyncAllowed = $derived(gitSyncStatus.enabled) + const isFreeTier = $derived(gitSyncAllowed && !$enterpriseLicense) + const hasConfiguredRepos = $derived( + gitSyncContext?.repositories?.some((r) => r.git_repo_resource_path) ?? false + ) + // Load settings when workspace context changes $effect(() => { if (gitSyncContext) { @@ -58,7 +92,7 @@ link="https://www.windmill.dev/docs/advanced/git_sync" > {#snippet actions()} - {#if $enterpriseLicense && gitSyncContext.repositories != undefined} + {#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined} - - {#if secondarySyncExpanded} -
    - {#if secondarySync.length === 0} -
    - No secondary sync repositories configured -
    - {:else} - {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)} -
    - -
    - {/each} - {/if} - - {#if !hasUnsavedSecondary} -
    - -
    - {/if} -
    - {/if} -
    - {:else} - - {#if !hasUnsavedSecondary} -
    - -
    - {/if} - {/if} - {/if} - - -
    - gitSyncContext.addPromotionRepository()} - isCollapsible={false} - showEmptyState={primaryPromotion?.repo === null} - /> - - - {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} - {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} + {#if $enterpriseLicense} + + {#if primarySync && !primarySync.repo?.isUnsavedConnection} + {#if secondarySync.length > 0 || secondarySyncExpanded}
    - {#if secondaryPromotionExpanded} + {#if secondarySyncExpanded}
    - {#if secondaryPromotion.length === 0} + {#if secondarySync.length === 0}
    - No secondary promotion repositories configured + No secondary sync repositories configured
    {:else} - {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} + {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
    {/each} {/if} - {#if !hasUnsavedSecondaryPromotion} + {#if !hasUnsavedSecondary}
    {/if} @@ -216,23 +187,99 @@ {/if}
    {:else} - - {#if !hasUnsavedSecondaryPromotion} + + {#if !hasUnsavedSecondary}
    {/if} {/if} {/if} -
    + + +
    + gitSyncContext.addPromotionRepository()} + isCollapsible={false} + showEmptyState={primaryPromotion?.repo === null} + /> + + + {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} + {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} +
    + + + {#if secondaryPromotionExpanded} +
    + {#if secondaryPromotion.length === 0} +
    + No secondary promotion repositories configured +
    + {:else} + {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} +
    + +
    + {/each} + {/if} + + {#if !hasUnsavedSecondaryPromotion} +
    + +
    + {/if} +
    + {/if} +
    + {:else} + + {#if !hasUnsavedSecondaryPromotion} +
    + +
    + {/if} + {/if} + {/if} +
    + {/if}
    From 10c5c97d3723dc317ed0a30098d248d91777d01a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 08:48:05 +0000 Subject: [PATCH 023/153] nit frontend --- frontend/src/lib/components/git_sync/GitSyncSection.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte index 9a03e43b27..8041c734da 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSection.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -120,7 +120,7 @@ {:else if isFreeTier}
    - + Git sync is an EE feature provided in CE for testing and hobbyist use when workspace members ≤ {gitSyncStatus.max_users}. Limited to a single repository. Upgrade to EE for multiple repositories, promotion mode, and GitHub App authentication. From 60804a96c630087958e3dc8b8ea0c87cbb690bce Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:50:12 +0100 Subject: [PATCH 024/153] refactor: unify eval pipeline with production chat code path (#8504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: unify eval pipeline with production chat code path Extract a shared headless runChatLoop() that both AIChatManager (production) and the eval runner use, with injectable SDK clients. Drop OpenRouter — evals now use direct provider APIs (OpenAI SDK, Anthropic SDK) with streaming, matching production behavior. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: re-read tools/helpers/systemMessage/model on each loop iteration The old chatRequest() re-read this.tools, this.helpers, this.systemMessage, and getCurrentModel() on every iteration. This matters because changeModeTool (Navigator → Script/Flow) reassigns all of these mid-loop. Use JS getters in the config object so runChatLoop picks up changes each iteration. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../copilot/chat/AIChatManager.svelte.ts | 163 ++++--------- .../chat/__tests__/app/appChat.eval.test.ts | 165 +++++++++---- .../chat/__tests__/app/appEvalComparison.ts | 31 +-- .../chat/__tests__/app/appEvalRunner.ts | 39 ++-- .../chat/__tests__/flow/flowChat.eval.test.ts | 151 ++++++++---- .../chat/__tests__/flow/flowEvalComparison.ts | 6 +- .../chat/__tests__/flow/flowEvalRunner.ts | 39 ++-- .../chat/__tests__/shared/baseEvalRunner.ts | 216 ++++++++---------- .../chat/__tests__/shared/baseLLMEvaluator.ts | 37 +-- .../copilot/chat/__tests__/shared/types.ts | 3 + .../lib/components/copilot/chat/anthropic.ts | 24 +- .../lib/components/copilot/chat/chatLoop.ts | 211 +++++++++++++++++ .../copilot/chat/openai-responses.ts | 23 +- .../src/lib/components/copilot/chat/shared.ts | 6 +- frontend/src/lib/components/copilot/lib.ts | 26 ++- 15 files changed, 743 insertions(+), 397 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/chatLoop.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 00a1a11fdf..140bbf4f14 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -23,8 +23,7 @@ import { } from './shared' import type { ChatCompletionMessageParam, - ChatCompletionSystemMessageParam, - ChatCompletionUserMessageParam + ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' import { prepareInlineChatSystemPrompt, @@ -37,7 +36,7 @@ import { loadApiTools } from './api/apiTools' import { prepareScriptUserMessage } from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' -import { getCompletion, getModelContextWindow, parseOpenAICompletion } from '../lib' +import { getModelContextWindow, workspaceAIClients } from '../lib' import { dfs } from '$lib/components/flows/previousResults' import { getStringError } from './utils' import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState' @@ -56,8 +55,7 @@ import type { import type { Selection } from 'monaco-editor' import type AIChatInput from './AIChatInput.svelte' import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core' -import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' -import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses' +import { runChatLoop } from './chatLoop' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore' @@ -413,130 +411,63 @@ class AIChatManager { systemMessage?: ChatCompletionSystemMessageParam }) => { try { - let addedMessages: ChatCompletionMessageParam[] = [] - while (true) { - const systemMessage = systemMessageOverride ?? this.systemMessage - const helpers = this.helpers - const tools = this.tools - for (const tool of tools) { - if (tool.setSchema) { - await tool.setSchema(helpers) - } - } - - let pendingPrompt = this.pendingPrompt - let pendingUserMessage: ChatCompletionUserMessageParam | undefined = undefined - if (pendingPrompt) { + // Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider + // on each iteration. This is critical for changeModeTool (Navigator → Script/Flow) + // which reassigns this.tools, this.helpers, this.systemMessage mid-loop. + const self = this + const result = await runChatLoop({ + messages, + get systemMessage() { + return systemMessageOverride ?? self.systemMessage + }, + get tools() { + return self.tools + }, + get helpers() { + return self.helpers + }, + abortController, + callbacks, + get modelProvider() { + return getCurrentModel() + }, + clients: { + openai: workspaceAIClients.getOpenaiClient(), + anthropic: workspaceAIClients.getAnthropicClient() + }, + workspace: get(workspaceStore) ?? '', + skipResponsesApi: this.skipResponsesApi, + onSkipResponsesApi: () => { + this.skipResponsesApi = true + }, + getPendingUserMessage: () => { + const pendingPrompt = this.pendingPrompt + if (!pendingPrompt) return undefined + this.pendingPrompt = '' if (this.mode === AIMode.SCRIPT) { - pendingUserMessage = prepareScriptUserMessage( + return prepareScriptUserMessage( pendingPrompt, this.contextManager.getSelectedContext() ) } else if (this.mode === AIMode.FLOW) { - pendingUserMessage = prepareFlowUserMessage( + return prepareFlowUserMessage( pendingPrompt, this.flowAiChatHelpers!.getFlowAndSelectedId() ) } else if (this.mode === AIMode.NAVIGATOR) { - pendingUserMessage = prepareNavigatorUserMessage(pendingPrompt) + return prepareNavigatorUserMessage(pendingPrompt) } - this.pendingPrompt = '' - } - - const model = getCurrentModel() - const isOpenAI = model.provider === 'openai' || model.provider === 'azure_openai' - const isAnthropic = model.provider === 'anthropic' - - const messageParams = [ - systemMessage, - ...messages, - ...(pendingUserMessage ? [pendingUserMessage] : []) - ] - const toolDefs = tools.map((t) => t.def) - - // For OpenAI/Azure, try Responses API first, fallback to Completions API - if (isOpenAI) { - let useCompletionsApi = this.skipResponsesApi - if (!this.skipResponsesApi) { - try { - const completion = await getOpenAIResponsesCompletion( - messageParams, - abortController, - toolDefs - ) - const continueCompletion = await parseOpenAIResponsesCompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break - } - } catch (err) { - console.warn('OpenAI Responses API failed, falling back to Completions API:', err) - // If the error indicates Responses API is not available in this region, skip it for future requests - const errorMessage = err instanceof Error ? err.message : String(err) - if (errorMessage.includes('Responses API is not enabled')) { - this.skipResponsesApi = true - } - useCompletionsApi = true - } - } - - // Use Completions API if Responses API is not available or failed - if (useCompletionsApi) { - const completion = await getCompletion(messageParams, abortController, toolDefs, { - forceCompletions: true - }) - const continueCompletion = await parseOpenAICompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break - } - } - } else if (isAnthropic) { - const completion = await getAnthropicCompletion(messageParams, abortController, toolDefs) - if (completion) { - const continueCompletion = await parseAnthropicCompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers, - abortController - ) - if (!continueCompletion) { - break - } - } - } else { - const completion = await getCompletion(messageParams, abortController, toolDefs) - if (completion) { - const continueCompletion = await parseOpenAICompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break + return undefined + }, + onBeforeIteration: async (tools) => { + for (const tool of tools) { + if (tool.setSchema) { + await tool.setSchema(this.helpers) } } } - } - return addedMessages + }) + return result.addedMessages } catch (err) { console.log('chatRequest error', err) console.error('chatRequest error', err) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts index 5183377caf..a42ee1f099 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts @@ -6,44 +6,77 @@ import { loadAppFixtureForEval } from './appFixtureLoader' import { dirname, join } from 'path' // @ts-ignore - Node.js url import { fileURLToPath } from 'url' +import type { AIProvider } from '$lib/gen/types.gen' -// Get API key from environment - tests will be skipped if not set +// Get API keys from environment - tests will be skipped if none are set // @ts-ignore -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const OPENAI_API_KEY = process.env.OPENAI_API_KEY +// @ts-ignore +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY -// Skip all tests if no API key is provided -const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip +const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY +const describeWithApiKey = hasAnyKey ? describe : describe.skip // Get __dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o'] +// Build model variants based on available keys +interface ModelVariant { + model: string + provider: AIProvider + apiKey: string +} + +const MODEL_VARIANTS: ModelVariant[] = [ + ...(OPENAI_API_KEY + ? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }] + : []), + ...(ANTHROPIC_API_KEY + ? [ + { + model: 'claude-haiku-4-5-20241022', + provider: 'anthropic' as AIProvider, + apiKey: ANTHROPIC_API_KEY + } + ] + : []) +] + const VARIANTS = [ - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...BASELINE_VARIANT, - model, - name: `baseline-${model.replace('/', '-')}` + model: mv.model, + name: `baseline-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })), - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...STREAMLINED_VARIANT, - model, - name: `streamlined-${model.replace('/', '-')}` + model: mv.model, + name: `streamlined-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })) ] describeWithApiKey('App Chat LLM Evaluation', () => { const TEST_TIMEOUT = 120_000 - if (!OPENROUTER_API_KEY) { - console.warn('OPENROUTER_API_KEY is not set, skipping tests') + if (!hasAnyKey) { + console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests') } it( 'test1: creates a simple counter app', async () => { const USER_PROMPT = `Create a counter app with increment/decrement buttons` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) - // Write results to files + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`App files: ${appPaths.join(', ')}`) @@ -56,17 +89,21 @@ describeWithApiKey('App Chat LLM Evaluation', () => { it( 'test2: modifies existing counter app to add reset button', async () => { - // Load initial app from fixture folder const { initialFrontend, initialBackend } = await loadAppFixtureForEval( join(__dirname, 'initial', 'test1_counter_app') ) const USER_PROMPT = `Add a reset button that sets the counter back to 0` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) - // Write results to files + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`App files: ${appPaths.join(', ')}`) @@ -86,10 +123,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a quantity selector (+ and - buttons) to each cart item so users can adjust quantities without removing and re-adding items` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -108,10 +151,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a discount code input field in the cart. When the code "SAVE10" is entered, apply a 10% discount to the total` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -132,10 +181,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a search bar in the toolbar that filters files and folders by name as the user types` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -154,10 +209,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Show file size (formatted as KB/MB) and modified date in the file list for each item` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -176,10 +237,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a "Select All" checkbox in the file list header and individual checkboxes for each file. Add a "Delete Selected" button that appears when items are selected` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -196,7 +263,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => { 'test8: create quiz app from scratch', async () => { const USER_PROMPT = `Create a multiple choice quiz app with 5 questions about general knowledge. Show one question at a time with 4 answer options. Track the score and show results at the end with percentage correct.` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -211,7 +284,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => { 'test9: create recipe book from scratch', async () => { const USER_PROMPT = `Create a recipe book app where users can add recipes with a name, ingredients list, and instructions. Include a search bar to filter recipes by name and the ability to delete recipes.` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts index 456299c142..e6c795d445 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts @@ -1,4 +1,4 @@ -import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { AppFiles, BackendRunnable } from '../../app/core' import { BASE_EVALUATOR_RESPONSE_FORMAT } from '../shared' import type { EvaluationResult } from '../shared' @@ -71,12 +71,7 @@ ${BASE_EVALUATOR_RESPONSE_FORMAT}` /** * Evaluates how well a generated app fulfills the user's request, considering any initial app state. - * This evaluator does not require an expected reference app - it evaluates based on the request alone. - * - * @param userPrompt The original user request - * @param generatedApp The app generated by the AI - * @param initialApp Optional initial app state (what the app looked like before AI changes) - * @returns Evaluation result with score, statement, and missing requirements + * Uses Anthropic API directly. */ export async function evaluateAppGeneration( userPrompt: string, @@ -84,9 +79,17 @@ export async function evaluateAppGeneration( initialApp?: InitialApp ): Promise { // @ts-ignore - const apiKey = process.env.OPENROUTER_API_KEY + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { + return { + success: false, + resemblanceScore: 0, + statement: 'No API key available for evaluation', + error: 'ANTHROPIC_API_KEY not set' + } + } - const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) + const client = new Anthropic({ apiKey }) let userMessage = `## User's Original Request ${userPrompt} @@ -117,16 +120,18 @@ Please evaluate how well the generated app: 2. ${initialApp ? 'Makes appropriate modifications to the initial app state' : 'Implements a complete and correct new app'}` try { - const response = await client.chat.completions.create({ - model: 'anthropic/claude-sonnet-4.5', + const response = await client.messages.create({ + model: 'claude-sonnet-4-5-20250514', + max_tokens: 2048, + system: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT, messages: [ - { role: 'system', content: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT }, { role: 'user', content: userMessage } ], temperature: 0 }) - const content = response.choices[0]?.message?.content + const textBlock = response.content.find((block) => block.type === 'text') + const content = textBlock?.text if (!content) { return { success: false, diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts index 3f0da73c92..2e6a491bce 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts @@ -14,6 +14,7 @@ import { type VariantDefaults } from '../shared' import { writeAppComparisonResultsToFolders } from './appResultsWriter' +import type { AIProvider } from '$lib/gen/types.gen' // Re-export for convenience export type { InitialApp } from './appEvalComparison' @@ -38,6 +39,8 @@ export interface AppEvalOptions { variant?: VariantConfig /** Whether to evaluate the generated app with LLM. Default: true. Set to false to skip evaluation. */ evaluateWithLLM?: boolean + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** @@ -49,12 +52,11 @@ const appDefaults: VariantDefaults = { } /** - * Runs an app chat evaluation with real OpenAI API calls. - * Executes tool calls using the actual app tools from core.ts or variant-configured tools. + * Runs an app chat evaluation using the shared chat loop (same code path as production). */ export async function runAppEval( userPrompt: string, - openaiApiKey: string, + apiKey: string, options?: AppEvalOptions ): Promise { const { helpers, getFiles } = createAppEvalHelpers( @@ -69,7 +71,7 @@ export async function runAppEval( appDefaults, options?.customSystemPrompt ) - const { toolDefs, tools } = resolveTools(options?.variant, appDefaults) + const { tools } = resolveTools(options?.variant, appDefaults) const model = resolveModel(options?.variant, options?.model) // Build user message @@ -80,15 +82,15 @@ export async function runAppEval( userPrompt, systemMessage, userMessage, - toolDefs, tools, helpers, - apiKey: openaiApiKey, + apiKey, getOutput: getFiles, options: { maxIterations: options?.maxIterations, model, - workspace: 'test-workspace' + workspace: 'test-workspace', + provider: options?.provider } }) @@ -114,21 +116,32 @@ export async function runAppEval( } } +/** + * Per-variant provider override. + */ +export interface VariantProviderOverride { + provider: AIProvider + apiKey: string +} + /** * Runs the same prompt against multiple variants sequentially for comparison. - * Returns results in the same order as the input variants. + * Accepts optional per-variant provider/apiKey overrides. */ export async function runVariantComparison( userPrompt: string, variants: VariantConfig[], - openaiApiKey: string, - baseOptions?: Omit + defaultApiKey: string, + baseOptions?: Omit, + providerOverrides?: VariantProviderOverride[] ): Promise { const results: AppEvalResult[] = await Promise.all( - variants.map(async (variant) => { - return await runAppEval(userPrompt, openaiApiKey, { + variants.map(async (variant, i) => { + const override = providerOverrides?.[i] + return await runAppEval(userPrompt, override?.apiKey ?? defaultApiKey, { ...baseOptions, - variant + variant, + provider: override?.provider ?? baseOptions?.provider }) }) ) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts index 8210ea50fb..de9b8e5f43 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts @@ -22,35 +22,60 @@ import initialTest6 from './initial/test6_initial.json' // @ts-ignore - JSON import import initialTest7 from './initial/test7_initial.json' import type { FlowModule } from '$lib/gen' +import type { AIProvider } from '$lib/gen/types.gen' -// Get API key from environment - tests will be skipped if not set +// Get API keys from environment - tests will be skipped if none are set // @ts-ignore -// const OPENAI_API_KEY = process.env.OPENAI_API_KEY -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const OPENAI_API_KEY = process.env.OPENAI_API_KEY +// @ts-ignore +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY -// Skip all tests if no API key is provided -// const describeWithApiKey = OPENAI_API_KEY ? describe : describe.skip -const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip +const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY +const describeWithApiKey = hasAnyKey ? describe : describe.skip -const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o'] +// Build model variants based on available keys +interface ModelVariant { + model: string + provider: AIProvider + apiKey: string +} + +const MODEL_VARIANTS: ModelVariant[] = [ + ...(OPENAI_API_KEY + ? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }] + : []), + ...(ANTHROPIC_API_KEY + ? [ + { + model: 'claude-haiku-4-5-20241022', + provider: 'anthropic' as AIProvider, + apiKey: ANTHROPIC_API_KEY + } + ] + : []) +] const VARIANTS = [ - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...BASELINE_VARIANT, - model, - name: `baseline-${model.replace('/', '-')}` + model: mv.model, + name: `baseline-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })), - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...MINIMAL_SINGLE_TOOL_VARIANT, - model, - name: `minimal-single-tool-${model.replace('/', '-')}` + model: mv.model, + name: `minimal-single-tool-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })) ] describeWithApiKey('Flow Chat LLM Evaluation', () => { const TEST_TIMEOUT = 120_000 - if (!OPENROUTER_API_KEY) { - console.warn('OPENROUTER_API_KEY is not set, skipping tests') + if (!hasAnyKey) { + console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests') } it( @@ -65,9 +90,15 @@ STEP 3: Loop on all users STEP 4: Do branches based on user's role, do different action based on that. Roles are admin, user, moderator STEP 5: Return action taken for each user ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest1 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest1 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) // Write results to files const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) @@ -112,9 +143,15 @@ STEP 5: Branch based on inventory - if all items available, create shipment reco STEP 6: Send confirmation (mock email to customer_email) STEP 7: Return final order summary with status ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest2 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest2 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -161,9 +198,15 @@ STEP 5: Branch based on quality score: - If score < 70: Store in quarantine and send alert STEP 6: Return processing report with statistics (total records, quality score, destination) ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest3 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest3 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -210,9 +253,15 @@ STEP 3: Use an AI agent to handle the customer query. The agent should have acce STEP 4: Log the interaction to audit trail (customer_id, query, response summary) STEP 5: Return the agent's response and any actions taken ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest4 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest4 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -256,11 +305,17 @@ Modify this existing flow to add error handling: - If validation passes, return the data for the next step - Update save_results to handle the validation result appropriately ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest5.value.modules as FlowModule[], - initialSchema: initialTest5.schema, - expectedFlow: expectedTest5 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest5.value.modules as FlowModule[], + initialSchema: initialTest5.schema, + expectedFlow: expectedTest5 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -302,11 +357,17 @@ Modify the order processing loop to handle different order types: - Move the original process_order step to the default branch for unknown order types - Each branch step should return the orderId, shipping cost, and shipping type ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest6.value.modules as FlowModule[], - initialSchema: initialTest6.schema, - expectedFlow: expectedTest6 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest6.value.modules as FlowModule[], + initialSchema: initialTest6.schema, + expectedFlow: expectedTest6 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -348,11 +409,17 @@ Refactor this flow for better performance by parallelizing the enrichment steps: - The combine_data step should check if any enrichment used a fallback value and set a hasFallbacks flag - Keep get_item as the first step and return_result as the last step unchanged ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest7.value.modules as FlowModule[], - initialSchema: initialTest7.schema, - expectedFlow: expectedTest7 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest7.value.modules as FlowModule[], + initialSchema: initialTest7.schema, + expectedFlow: expectedTest7 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts index f55979bb40..4c2b41d577 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts @@ -59,14 +59,10 @@ export async function evaluateFlowComparison( expectedFlow: ExpectedFlow, userPrompt: string ): Promise { - // @ts-ignore - const apiKey = process.env.OPENROUTER_API_KEY - return evaluateWithLLM({ userPrompt, generatedOutput: generatedFlow, expectedOutput: expectedFlow, - evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT, - apiKey + evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT }) } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts index 3f27143c69..f3c976950d 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts @@ -1,4 +1,5 @@ import type { FlowModule } from '$lib/gen' +import type { AIProvider } from '$lib/gen/types.gen' import type { ExtendedOpenFlow } from '$lib/components/flows/types' import { flowTools, prepareFlowSystemMessage, prepareFlowUserMessage, type FlowAIChatHelpers } from '../../flow/core' import { createFlowEvalHelpers } from './flowEvalHelpers' @@ -38,6 +39,8 @@ export interface FlowEvalOptions { maxIterations?: number variant?: VariantConfig expectedFlow?: ExpectedFlow + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** @@ -49,12 +52,11 @@ const flowDefaults: VariantDefaults = { } /** - * Runs a flow chat evaluation with real OpenAI API calls. - * Executes tool calls using the actual flowTools from core.ts or variant-configured tools. + * Runs a flow chat evaluation using the shared chat loop (same code path as production). */ export async function runFlowEval( userPrompt: string, - openaiApiKey: string, + apiKey: string, options?: FlowEvalOptions ): Promise { const { helpers, getFlow } = createFlowEvalHelpers( @@ -65,7 +67,7 @@ export async function runFlowEval( // Resolve variant configuration const variantName = options?.variant?.name ?? 'baseline' const systemMessage = resolveSystemPrompt(options?.variant, flowDefaults, options?.customSystemPrompt) - const { toolDefs, tools } = resolveTools(options?.variant, flowDefaults) + const { tools } = resolveTools(options?.variant, flowDefaults) const model = resolveModel(options?.variant, options?.model) // Build user message @@ -76,15 +78,15 @@ export async function runFlowEval( userPrompt, systemMessage, userMessage, - toolDefs, tools, helpers, - apiKey: openaiApiKey, + apiKey, getOutput: getFlow, options: { maxIterations: options?.maxIterations, model, - workspace: 'test-workspace' + workspace: 'test-workspace', + provider: options?.provider } }) @@ -111,21 +113,32 @@ export async function runFlowEval( } } +/** + * Per-variant provider override. + */ +export interface VariantProviderOverride { + provider: AIProvider + apiKey: string +} + /** * Runs the same prompt against multiple variants sequentially for comparison. - * Returns results in the same order as the input variants. + * Accepts optional per-variant provider/apiKey overrides. */ export async function runVariantComparison( userPrompt: string, variants: VariantConfig[], - openaiApiKey: string, - baseOptions?: Omit + defaultApiKey: string, + baseOptions?: Omit, + providerOverrides?: VariantProviderOverride[] ): Promise { const results: FlowEvalResult[] = await Promise.all( - variants.map(async (variant) => { - return await runFlowEval(userPrompt, openaiApiKey, { + variants.map(async (variant, i) => { + const override = providerOverrides?.[i] + return await runFlowEval(userPrompt, override?.apiKey ?? defaultApiKey, { ...baseOptions, - variant + variant, + provider: override?.provider ?? baseOptions?.provider }) }) ) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts index b9b7820568..f46acb9108 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts @@ -1,8 +1,14 @@ -import OpenAI, { APIError } from 'openai' -import type { ChatCompletionMessageParam, ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' -import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs' +import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' +import type { + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam +} from 'openai/resources/chat/completions.mjs' +import type { AIProvider, AIProviderModel } from '$lib/gen/types.gen' import type { TokenUsage, ToolCallDetail, EvalRunnerOptions } from './types' import type { Tool } from './baseVariants' +import { runChatLoop, type ChatClients } from '../../chatLoop' +import type { Tool as ProductionTool, ToolCallbacks } from '../../shared' /** * Result from a single eval run (before domain-specific evaluation). @@ -29,13 +35,13 @@ export interface RunEvalParams { systemMessage: ChatCompletionSystemMessageParam /** User message for the LLM */ userMessage: ChatCompletionMessageParam - /** Tool definitions for the LLM API */ - toolDefs: ChatCompletionTool[] + /** Tool definitions for the LLM API (unused — derived from tools) */ + toolDefs?: unknown /** Full tool implementations for execution */ tools: Tool[] /** Domain-specific helpers for tool execution */ helpers: THelpers - /** API key for OpenRouter */ + /** API key for the provider */ apiKey: string /** Function to get the current output state */ getOutput: () => TOutput @@ -44,10 +50,37 @@ export interface RunEvalParams { } /** - * Runs a generic evaluation with real LLM API calls. - * Executes tool calls in a loop until the LLM stops calling tools. - * - * This is the core execution loop shared across all chat eval tests. + * Creates SDK clients for the given provider. + */ +function createEvalClients(provider: AIProvider, apiKey: string): ChatClients { + if (provider === 'anthropic') { + return { + openai: new OpenAI({ apiKey: 'unused' }), + anthropic: new Anthropic({ apiKey }) + } + } + return { + openai: new OpenAI({ apiKey }), + anthropic: new Anthropic({ apiKey: 'unused' }) + } +} + +/** + * Resolves model string to AIProviderModel. + */ +function resolveModelProvider( + model: string, + provider?: AIProvider +): AIProviderModel { + if (provider) return { provider, model } + if (model.startsWith('claude')) return { provider: 'anthropic', model } + if (model.startsWith('gpt') || model.startsWith('o')) return { provider: 'openai', model } + return { provider: 'openai', model } +} + +/** + * Runs a generic evaluation using the shared chat loop (same code path as production). + * Uses streaming via real provider SDKs instead of OpenRouter non-streaming. */ export async function runEval( params: RunEvalParams @@ -55,7 +88,6 @@ export async function runEval( const { systemMessage, userMessage, - toolDefs, tools, helpers, apiKey, @@ -63,134 +95,82 @@ export async function runEval( options } = params - const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) const model = options?.model ?? 'gpt-4o' const maxIterations = options?.maxIterations ?? 20 const workspace = options?.workspace ?? 'test-workspace' + const provider = options?.provider - const messages: ChatCompletionMessageParam[] = [systemMessage, userMessage] - const totalTokens: TokenUsage = { prompt: 0, completion: 0, total: 0 } + const modelProvider = resolveModelProvider(model, provider) + const clients = createEvalClients(modelProvider.provider, apiKey) + + const messages: ChatCompletionMessageParam[] = [userMessage] let toolCallsCount = 0 const toolsCalled: string[] = [] const toolCallDetails: ToolCallDetail[] = [] - let iterations = 0 - // No-op tool callbacks for eval - const toolCallbacks = { + // Wrap tools to intercept fn calls for tracking. + // Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type + // but the actual callbacks passed at runtime will satisfy both interfaces. + const wrappedTools = tools.map((tool) => ({ + ...tool, + fn: async (p: any) => { + toolCallsCount++ + toolsCalled.push(tool.def.function.name) + try { + const args = + typeof p.args === 'string' ? JSON.parse(p.args) : p.args + toolCallDetails.push({ name: tool.def.function.name, arguments: args }) + } catch { + toolCallDetails.push({ + name: tool.def.function.name, + arguments: p.args + }) + } + return tool.fn(p) + } + })) as ProductionTool[] + + // No-op callbacks for eval + const callbacks: ToolCallbacks & { + onNewToken: (token: string) => void + onMessageEnd: () => void + } = { setToolStatus: () => {}, - removeToolStatus: () => {} + removeToolStatus: () => {}, + onNewToken: () => {}, + onMessageEnd: () => {} } + const abortController = new AbortController() + try { - // Tool resolution loop - while (iterations < maxIterations) { - iterations++ - - const response = await client.chat.completions.create({ - model, - messages, - tools: toolDefs, - temperature: 0 - }) - - // Track token usage - if (response.usage) { - totalTokens.prompt += response.usage.prompt_tokens - totalTokens.completion += response.usage.completion_tokens - totalTokens.total += response.usage.total_tokens - } - - if (!response.choices.length) { - throw new Error('No response from API') - } - - const choice = response.choices[0] - const assistantMessage = choice.message - - // Add assistant message to history - messages.push(assistantMessage) - - // If no tool calls, we're done - if (!assistantMessage.tool_calls?.length) { - break - } - - // Execute each tool call - for (const toolCall of assistantMessage.tool_calls) { - toolCallsCount++ - - // Type guard: only handle function tool calls - if (toolCall.type !== 'function') { - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Unsupported tool type: ${toolCall.type}` - }) - continue - } - - toolsCalled.push(toolCall.function.name) - - const tool = tools.find((t) => t.def.function.name === toolCall.function.name) - if (!tool) { - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Unknown tool: ${toolCall.function.name}` - }) - continue - } - - try { - const args = JSON.parse(toolCall.function.arguments) - toolCallDetails.push({ name: toolCall.function.name, arguments: args }) - const result = await tool.fn({ - args, - workspace, - helpers, - toolCallbacks, - toolId: toolCall.id - }) - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: result - }) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Error: ${errorMessage}` - }) - } - } - } + const result = await runChatLoop({ + messages, + systemMessage, + tools: wrappedTools, + helpers, + abortController, + callbacks, + modelProvider, + clients, + workspace, + maxIterations, + skipResponsesApi: modelProvider.provider !== 'openai' && modelProvider.provider !== 'azure_openai' + }) return { success: true, output: getOutput(), - tokenUsage: totalTokens, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, toolCallsCount, toolsCalled, toolCallDetails, - iterations, + iterations: Math.max(1, result.addedMessages.filter((m) => m.role === 'assistant').length), messages } } catch (err) { - // Build detailed error message let errorMessage: string - if (err instanceof APIError) { - const details: string[] = [`${err.status} ${err.message}`] - if (err.code) details.push(`Code: ${err.code}`) - if (err.type) details.push(`Type: ${err.type}`) - if (err.param) details.push(`Param: ${err.param}`) - if (err.requestID) details.push(`Request ID: ${err.requestID}`) - if (err.error && typeof err.error === 'object') { - details.push(`Response: ${JSON.stringify(err.error, null, 2)}`) - } - errorMessage = details.join('\n') - } else if (err instanceof Error) { + if (err instanceof Error) { errorMessage = err.stack ?? err.message } else { errorMessage = String(err) @@ -200,11 +180,11 @@ export async function runEval( success: false, output: getOutput(), error: errorMessage, - tokenUsage: totalTokens, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, toolCallsCount, toolsCalled, toolCallDetails, - iterations, + iterations: 0, messages } } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts index 63c17828f4..bd7bd06d44 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts @@ -1,4 +1,4 @@ -import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { EvaluationResult } from './types' /** @@ -13,9 +13,9 @@ export interface EvaluateParams { expectedOutput: unknown /** Domain-specific system prompt for the evaluator */ evaluatorSystemPrompt: string - /** API key for OpenRouter */ - apiKey: string - /** Model to use for evaluation (default: 'anthropic/claude-sonnet-4.5') */ + /** Anthropic API key for evaluation */ + apiKey?: string + /** Model to use for evaluation (default: 'claude-sonnet-4-5-20250514') */ model?: string } @@ -41,10 +41,7 @@ Score guidelines: /** * Evaluates how well a generated output matches an expected output using an LLM. - * Returns a resemblance score (0-100), a qualitative statement, and any missing requirements. - * - * @param params Evaluation parameters including prompts, outputs, and API configuration - * @returns Evaluation result with score, statement, and missing requirements + * Uses Anthropic API directly instead of OpenRouter. */ export async function evaluateWithLLM(params: EvaluateParams): Promise { const { @@ -53,10 +50,21 @@ export async function evaluateWithLLM(params: EvaluateParams): Promise block.type === 'text') + const content = textBlock?.text if (!content) { return { success: false, @@ -98,7 +108,6 @@ Please evaluate how well the generated output: // Parse JSON response - handle potential markdown code blocks let jsonContent = content.trim() if (jsonContent.startsWith('```')) { - // Remove markdown code block wrapper jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '') } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts index 021e776440..61f7f1fd1f 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts @@ -1,4 +1,5 @@ import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs' +import type { AIProvider } from '$lib/gen/types.gen' /** * Token usage tracking for LLM calls. @@ -83,6 +84,8 @@ export interface EvalRunnerOptions { model?: string /** Workspace ID for tool calls */ workspace?: string + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 03d0f363a0..ac45c175a6 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -1,4 +1,5 @@ import { OpenAI } from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { ChatCompletionMessageParam, ChatCompletionMessageFunctionToolCall @@ -13,19 +14,28 @@ import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources' import type { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream' +import type { AIProviderModel } from '$lib/gen' import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib' import { processToolCall, type Tool, type ToolCallbacks } from './shared' export async function getAnthropicCompletion( messages: ChatCompletionMessageParam[], abortController: AbortController, - tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[] + tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[], + options?: { + forceModelProvider?: AIProviderModel + anthropicClient?: Anthropic + } ): Promise { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + forceModelProvider: options?.forceModelProvider + }) const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages) const anthropicTools = convertOpenAIToolsToAnthropic(tools) - const anthropicClient = workspaceAIClients.getAnthropicClient() + const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient() const anthropicParams = { model: config.model, @@ -36,7 +46,7 @@ export async function getAnthropicCompletion( ...(typeof config.temperature === 'number' && { temperature: config.temperature }) } - const stream = anthropicClient.messages.stream(anthropicParams, { + const stream = client.messages.stream(anthropicParams, { signal: abortController.signal, headers: { 'X-Provider': provider, @@ -58,7 +68,8 @@ export async function parseAnthropicCompletion( addedMessages: ChatCompletionMessageParam[], tools: Tool[], helpers: any, - abortController?: AbortController + abortController?: AbortController, + options?: { workspace?: string } ): Promise { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error = null @@ -209,7 +220,8 @@ export async function parseAnthropicCompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts new file mode 100644 index 0000000000..4b239e4a05 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -0,0 +1,211 @@ +import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' +import type { + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam +} from 'openai/resources/chat/completions.mjs' +import type { AIProviderModel } from '$lib/gen' +import { getCompletion, parseOpenAICompletion } from '../lib' +import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' +import { + getOpenAIResponsesCompletion, + parseOpenAIResponsesCompletion +} from './openai-responses' +import type { Tool, ToolCallbacks } from './shared' + +export interface ChatClients { + openai: OpenAI + anthropic: Anthropic +} + +export interface ChatLoopConfig { + messages: ChatCompletionMessageParam[] + /** + * System message, tools, helpers, and modelProvider are re-read from this config + * on every iteration. Callers can use JS getters to provide dynamic values + * (e.g. AIChatManager uses getters so mode changes mid-loop take effect). + */ + systemMessage: ChatCompletionSystemMessageParam + tools: Tool[] + helpers: any + abortController: AbortController + callbacks: ToolCallbacks & { + onNewToken: (token: string) => void + onMessageEnd: () => void + } + modelProvider: AIProviderModel + clients: ChatClients + workspace: string + /** Maximum iterations for the loop. undefined = unlimited (production). */ + maxIterations?: number + skipResponsesApi?: boolean + onSkipResponsesApi?: () => void + /** Return a pending user message to inject between iterations, or undefined. */ + getPendingUserMessage?: () => ChatCompletionUserMessageParam | undefined + /** Called before each iteration (e.g. to refresh tool schemas). */ + onBeforeIteration?: (tools: Tool[], helpers: any) => Promise +} + +export interface ChatLoopResult { + addedMessages: ChatCompletionMessageParam[] +} + +export async function runChatLoop(config: ChatLoopConfig): Promise { + const { + messages, + abortController, + callbacks, + clients, + workspace, + maxIterations, + onSkipResponsesApi, + getPendingUserMessage, + onBeforeIteration + } = config + let skipResponsesApi = config.skipResponsesApi ?? false + + const addedMessages: ChatCompletionMessageParam[] = [] + let iterations = 0 + + while (true) { + if (maxIterations !== undefined && iterations >= maxIterations) { + break + } + iterations++ + + // Re-read these from config each iteration so that mode changes + // (e.g. changeModeTool in Navigator) take effect immediately. + // Callers can use JS getter properties to provide dynamic values. + const tools = config.tools + const helpers = config.helpers + const systemMessage = config.systemMessage + const modelProvider = config.modelProvider + + if (onBeforeIteration) { + await onBeforeIteration(tools, helpers) + } + + const pendingUserMessage = getPendingUserMessage?.() + + const isOpenAI = + modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai' + const isAnthropic = modelProvider.provider === 'anthropic' + + const messageParams = [ + systemMessage, + ...messages, + ...(pendingUserMessage ? [pendingUserMessage] : []) + ] + const toolDefs = tools.map((t) => t.def) + const parseOptions = { workspace } + + if (isOpenAI) { + let useCompletionsApi = skipResponsesApi + if (!skipResponsesApi) { + try { + const completion = await getOpenAIResponsesCompletion( + messageParams, + abortController, + toolDefs, + { + forceModelProvider: modelProvider, + openaiClient: clients.openai + } + ) + const continueCompletion = await parseOpenAIResponsesCompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + parseOptions + ) + if (!continueCompletion) { + break + } + } catch (err) { + console.warn( + 'OpenAI Responses API failed, falling back to Completions API:', + err + ) + const errorMessage = err instanceof Error ? err.message : String(err) + if (errorMessage.includes('Responses API is not enabled')) { + skipResponsesApi = true + onSkipResponsesApi?.() + } + useCompletionsApi = true + } + } + + if (useCompletionsApi) { + const completion = await getCompletion(messageParams, abortController, toolDefs, { + forceCompletions: true, + forceModelProvider: modelProvider, + openaiClient: clients.openai + }) + const continueCompletion = await parseOpenAICompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + undefined, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } else if (isAnthropic) { + const completion = await getAnthropicCompletion( + messageParams, + abortController, + toolDefs, + { + forceModelProvider: modelProvider, + anthropicClient: clients.anthropic + } + ) + if (completion) { + const continueCompletion = await parseAnthropicCompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + abortController, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } else { + const completion = await getCompletion(messageParams, abortController, toolDefs, { + forceModelProvider: modelProvider, + openaiClient: clients.openai + }) + if (completion) { + const continueCompletion = await parseOpenAICompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + undefined, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } + } + + return { addedMessages } +} diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index 5003f48099..56364e1401 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -125,15 +125,24 @@ function convertCompletionConfigToResponsesConfig( export async function getOpenAIResponsesCompletion( messages: ChatCompletionMessageParam[], abortController: AbortController, - tools?: OpenAI.Chat.Completions.ChatCompletionTool[] + tools?: OpenAI.Chat.Completions.ChatCompletionTool[], + options?: { + forceModelProvider?: AIProviderModel + openaiClient?: OpenAI + } ) { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + tools, + forceModelProvider: options?.forceModelProvider + }) const { instructions, input } = convertMessagesToResponsesInput(messages) const responsesConfig = convertCompletionConfigToResponsesConfig(config) - const openaiClient = workspaceAIClients.getOpenaiClient() + const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() - const runner = openaiClient.responses.stream( + const runner = client.responses.stream( { ...responsesConfig, input, @@ -208,7 +217,8 @@ export async function parseOpenAIResponsesCompletion( messages: ChatCompletionMessageParam[], addedMessages: ChatCompletionMessageParam[], tools: Tool[], - helpers: any + helpers: any, + options?: { workspace?: string } ): Promise { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error: OpenAIError | ResponseErrorEvent | null = null @@ -342,7 +352,8 @@ export async function parseOpenAIResponsesCompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 4a95912b47..20e488d923 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -417,12 +417,14 @@ export async function processToolCall({ tools, toolCall, helpers, - toolCallbacks + toolCallbacks, + workspace }: { tools: Tool[] toolCall: ChatCompletionMessageFunctionToolCall helpers: T toolCallbacks: ToolCallbacks + workspace?: string }): Promise { try { const args = JSON.parse(toolCall.function.arguments || '{}') @@ -472,7 +474,7 @@ export async function processToolCall({ tools, functionName: toolCall.function.name, args, - workspace: get(workspaceStore) ?? '', + workspace: workspace ?? get(workspaceStore) ?? '', helpers, toolCallbacks, toolId: toolCall.id diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index dc05e3a247..d8149086d4 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -296,7 +296,12 @@ function getModelSpecificConfig( ) { const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model) const modelKey = `${modelProvider.provider}:${modelProvider.model}` - const customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel + let customMaxTokensStore: Record | undefined + try { + customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel + } catch { + // copilotInfo store may not be initialized in vitest + } const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens if ( (modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai') && @@ -876,9 +881,16 @@ export async function getCompletion( tools?: OpenAI.Chat.Completions.ChatCompletionTool[], options?: { forceCompletions?: boolean + forceModelProvider?: AIProviderModel + openaiClient?: OpenAI } ): Promise> { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + tools, + forceModelProvider: options?.forceModelProvider + }) // Use Responses API for OpenAI and Azure OpenAI if ((provider === 'openai' || provider === 'azure_openai') && !options?.forceCompletions) { @@ -891,8 +903,8 @@ export async function getCompletion( } // Use Completions API for other providers - const openaiClient = workspaceAIClients.getOpenaiClient() - const completion = openaiClient.chat.completions.create(config, { + const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() + const completion = client.chat.completions.create(config, { signal: abortController.signal, headers: { 'X-Provider': provider @@ -921,7 +933,8 @@ export async function parseOpenAICompletion( addedMessages: ChatCompletionMessageParam[], tools: Tool[], helpers: any, - _abortController?: AbortController // unused, for signature compatibility with parseAnthropicCompletion + _abortController?: AbortController, // unused, for signature compatibility with parseAnthropicCompletion + options?: { workspace?: string } ): Promise { const finalToolCalls: Record = {} let malformedFunctionCallError = false @@ -1060,7 +1073,8 @@ export async function parseOpenAICompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) From 34e3115bcbd19a8e0b6f483435586a2ab43d0a8e Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:59:48 +0100 Subject: [PATCH 025/153] fix: raw apps bundle not found during deployment error (#8515) --- backend/Cargo.lock | 1 + backend/windmill-api-workspaces/Cargo.toml | 2 + .../windmill-api-workspaces/src/workspaces.rs | 65 +++++++++++++++++++ backend/windmill-api/Cargo.toml | 2 +- backend/windmill-api/src/apps.rs | 26 ++++++-- 5 files changed, 88 insertions(+), 8 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a5e2e8fc98..34532bc173 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16433,6 +16433,7 @@ dependencies = [ "windmill-common", "windmill-dep-map", "windmill-git-sync", + "windmill-object-store", "windmill-queue", "windmill-types", ] diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml index a03bb3a490..86f0649c73 100644 --- a/backend/windmill-api-workspaces/Cargo.toml +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -14,9 +14,11 @@ enterprise = ["windmill-common/enterprise"] private = ["windmill-common/private"] cloud = ["windmill-common/cloud"] no_auth = ["windmill-api-auth/no_auth"] +parquet = ["windmill-object-store/parquet"] [dependencies] windmill-common = { workspace = true, default-features = false } +windmill-object-store = { workspace = true, optional = true } windmill-types.workspace = true windmill-api-auth.workspace = true windmill-api-users.workspace = true diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index fb234a13a3..4e832b3a93 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3474,6 +3474,11 @@ async fn clone_apps( .fetch_all(&mut **tx) .await?; + let mut cloned_from_db: std::collections::HashSet<(i64, String)> = HashSet::new(); + for bundle in &bundles { + cloned_from_db.insert((bundle.app_version_id, bundle.file_type.clone())); + } + for bundle in bundles { if let Some(&new_version_id) = version_id_mapping.get(&bundle.app_version_id) { sqlx::query!( @@ -3488,6 +3493,66 @@ async fn clone_apps( .await?; } } + + // Clone bundles from S3 for versions not found in DB + #[cfg(all(feature = "enterprise", feature = "parquet"))] + { + let object_store = windmill_object_store::get_object_store().await; + if let Some(os) = object_store { + for (&old_version_id, &new_version_id) in &version_id_mapping { + for file_type in &["js", "css"] { + if cloned_from_db.contains(&(old_version_id, file_type.to_string())) { + continue; + } + let src_path = format!( + "/app_bundles/{}/{}.{}", + source_workspace_id, old_version_id, file_type + ); + let get_result = os + .get(&windmill_object_store::object_store_reexports::Path::from( + src_path, + )) + .await; + match get_result { + Ok(result) => { + let data = result.bytes().await.map_err( + windmill_object_store::object_store_error_to_error, + )?; + let dst_path = format!( + "/app_bundles/{}/{}.{}", + target_workspace_id, new_version_id, file_type + ); + os.put( + &windmill_object_store::object_store_reexports::Path::from( + dst_path.clone(), + ), + data.into(), + ) + .await + .map_err( + windmill_object_store::object_store_error_to_error, + )?; + tracing::info!( + "Cloned app bundle from S3: {}.{} -> {}.{}", + old_version_id, + file_type, + new_version_id, + file_type + ); + } + Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => { + // No bundle in S3 for this version/type, skip + } + Err(e) => { + return Err( + windmill_object_store::object_store_error_to_error(e), + ); + } + } + } + } + } + } } // Update app versions arrays diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 1b09f37861..3f015513e2 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -18,7 +18,7 @@ agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"] enterprise_saml = ["dep:samael", "dep:libxml"] benchmark = [] embedding = ["windmill-api-embeddings/embedding"] -parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"] +parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"] prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus"] openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"] tantivy = ["dep:windmill-indexer"] diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 33a632ecbd..eaead14558 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -440,17 +440,29 @@ async fn get_raw_app_data( #[cfg(all(feature = "enterprise", feature = "parquet"))] if let Some(os) = object_store { let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type); - let stream = os + match os .get(&windmill_object_store::object_store_reexports::Path::from( path, )) .await - .map_err(windmill_object_store::object_store_error_to_error)? - .bytes() - .await - .map_err(windmill_object_store::object_store_error_to_error)?; - tracing::info!("stream: {}", stream.len()); - body = Some(Body::from(stream)); + { + Ok(result) => { + let stream = result + .bytes() + .await + .map_err(windmill_object_store::object_store_error_to_error)?; + tracing::info!("stream: {}", stream.len()); + body = Some(Body::from(stream)); + } + Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { + .. + }) => { + // S3 key not found, fall through to DB lookup below + } + Err(e) => { + return Err(windmill_object_store::object_store_error_to_error(e)); + } + } } if body.is_none() { From b7d14c8614f4da0da262bb20c0eb01854975cf65 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 11:20:39 +0000 Subject: [PATCH 026/153] regenerate sqlx offline query cache for integration tests (#8518) Co-authored-by: Claude Opus 4.5 --- ...aacf6af2c284ae446860113c82bc4e1da08ab.json | 12 ++++++++++ ...bc47caebc25215a430d6b301b35e265888159.json | 12 ++++++++++ ...fb7cf5f2b76f013c274245af13d7d727ebf1f.json | 12 ++++++++++ ...e2e60e3183fa81a411622891caea6dc03fa90.json | 15 +++++++++++++ ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...960ffc33da5f31bf780e8fd6a66d5150b8027.json | 12 ++++++++++ ...69c87a9d29370ec985d2c8c28633cd078ffaf.json | 12 ++++++++++ ...74da8c73120b3e16194904575f79a4e055002.json | 12 ++++++++++ ...437ab3e02d8c3c10c53decc664533b8d04bc0.json | 22 +++++++++++++++++++ 9 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json create mode 100644 backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json create mode 100644 backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json create mode 100644 backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json create mode 100644 backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json create mode 100644 backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json create mode 100644 backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json create mode 100644 backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json diff --git a/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json b/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json new file mode 100644 index 0000000000..0ad1fe4367 --- /dev/null +++ b/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app_version (id, app_id, value, created_by, created_at)\n VALUES (3001, 3001, '{\"grid\": []}', 'admin', NOW())", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab" +} diff --git a/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json b/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json new file mode 100644 index 0000000000..24d3c8929a --- /dev/null +++ b/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159" +} diff --git a/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json b/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json new file mode 100644 index 0000000000..10cab9117a --- /dev/null +++ b/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms)\n VALUES ('test-workspace', 'u/operator/existing_flow', 'Existing flow', '', '{\"modules\": []}', 'admin', NOW(), '{}', '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f" +} 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-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json b/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json new file mode 100644 index 0000000000..8e558fe67b --- /dev/null +++ b/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms)\n VALUES (3001, 'test-workspace', 'u/operator/existing_app', 'Existing app', '{}',\n '{\"on_behalf_of\": \"u/admin\", \"on_behalf_of_email\": \"admin@windmill.dev\", \"execution_mode\": \"viewer\"}', '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027" +} diff --git a/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json b/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json new file mode 100644 index 0000000000..6da123cbc4 --- /dev/null +++ b/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'editors', 'charlie')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf" +} diff --git a/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json b/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json new file mode 100644 index 0000000000..d7cc49fe3f --- /dev/null +++ b/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms)\n VALUES ('test-workspace', 3001, 'u/operator/existing_script', 'export function main() { return \"original\"; }', 'deno', 'script', 'admin', '{}', 'Existing script', '', '', '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002" +} diff --git a/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json b/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json new file mode 100644 index 0000000000..d9b7688eba --- /dev/null +++ b/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id,\n script_path, is_flow, workspace_id, edited_by, permissioned_as\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "VarcharArray", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0" +} From 520706b640a1f9c8470d41f169b76d90db70a1e4 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:01:03 +0100 Subject: [PATCH 027/153] chore: use workingdir in webmux panes (#8516) Co-authored-by: Claude Opus 4.5 --- .webmux.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.webmux.yaml b/.webmux.yaml index 6a765fbb12..c41d0aa699 100644 --- a/.webmux.yaml +++ b/.webmux.yaml @@ -55,11 +55,13 @@ profiles: - id: backend kind: command split: right - command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/backend" && cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}" + workingDir: backend + command: PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}" - id: frontend kind: command split: bottom - command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0 + workingDir: frontend + command: npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0 frontendOnly: runtime: host @@ -82,7 +84,8 @@ profiles: - id: frontend kind: command split: right - command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0 + workingDir: frontend + command: npm run generate-backend-client && npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0 agentOnly: runtime: host From 0904d7fffeb0cc4ad1627b60ebc0340cfad74bcf Mon Sep 17 00:00:00 2001 From: Samuel Wilk <34423885+da-wilky@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:51:18 +0100 Subject: [PATCH 028/153] Add 'fast' query parameter to API definition (#8521) --- backend/windmill-api/openapi.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index a8f83c368a..825842f057 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10644,6 +10644,10 @@ paths: in: query schema: type: boolean + - name: fast + in: query + schema: + type: boolean responses: "200": From 34cf0a0324627d4da6d4324ab662155045ddf327 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 13:51:34 +0000 Subject: [PATCH 029/153] show sync resource types button when resource type is missing (#8514) * feat: show sync resource types button when resource type is missing Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show prominent error message when resource type is not found Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use sync_cached_resource_types endpoint instead of hub_sync script Co-Authored-By: Claude Opus 4.6 (1M context) * fix: fallback to fetching resource types from hub when cache file missing Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-api-settings/src/lib.rs | 69 ++++++++++++--- .../src/lib/components/ApiConnectForm.svelte | 6 +- .../src/lib/components/AppConnectInner.svelte | 87 +++++++++---------- .../src/lib/components/ResourceEditor.svelte | 12 ++- .../lib/components/SyncResourceTypes.svelte | 41 +++++++++ 5 files changed, 153 insertions(+), 62 deletions(-) create mode 100644 frontend/src/lib/components/SyncResourceTypes.svelte diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index dbe80aca94..d1b52a8cc3 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -1124,6 +1124,59 @@ struct CachedResourceType { description: Option, } +#[derive(serde::Deserialize)] +struct HubResourceTypeRaw { + id: i64, + name: String, + schema: Option, + app: String, + description: Option, +} + +async fn fetch_resource_types_from_hub() -> error::Result> { + let response = HTTP_CLIENT + .get(format!( + "{}/resource_types/list", + windmill_common::DEFAULT_HUB_BASE_URL + )) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| error::Error::InternalErr(format!("Failed to fetch from hub: {}", e)))?; + + if !response.status().is_success() { + return Err(error::Error::InternalErr(format!( + "Hub returned status {}", + response.status() + ))); + } + + let raw_types: Vec = response + .json() + .await + .map_err(|e| error::Error::InternalErr(format!("Failed to parse hub response: {}", e)))?; + + Ok(raw_types + .into_iter() + .filter_map(|rt| { + let schema = match rt.schema { + Some(s) => match serde_json::from_str(&s) { + Ok(v) => Some(v), + Err(_) => return None, + }, + None => None, + }; + Some(CachedResourceType { + id: rt.id, + name: rt.name, + schema, + app: rt.app, + description: rt.description, + }) + }) + .collect()) +} + async fn sync_cached_resource_types( Extension(db): Extension, authed: ApiAuthed, @@ -1133,16 +1186,12 @@ async fn sync_cached_resource_types( use windmill_common::worker::HUB_RT_CACHE_DIR; let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR); - let content = tokio::fs::read_to_string(&cache_path).await.map_err(|e| { - error::Error::NotFound(format!( - "No cached resource types found at {}: {}", - cache_path, e - )) - })?; - - let cached_types: Vec = serde_json::from_str(&content).map_err(|e| { - error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e)) - })?; + let cached_types = match tokio::fs::read_to_string(&cache_path).await { + Ok(content) => serde_json::from_str::>(&content).map_err(|e| { + error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e)) + })?, + Err(_) => fetch_resource_types_from_hub().await?, + }; let mut synced_count = 0; diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 636a20f290..7d10db1d48 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -16,6 +16,7 @@ import BedrockCredentialsCheck from './BedrockCredentialsCheck.svelte' import { isCloudHosted } from '$lib/cloud' import ResourceGen from './copilot/ResourceGen.svelte' + import SyncResourceTypes from './SyncResourceTypes.svelte' interface Props { resourceType: string @@ -25,6 +26,7 @@ isValid?: boolean linkedSecretCandidates?: string[] | undefined description?: string | undefined + onSynced?: () => void } let { @@ -34,7 +36,8 @@ linkedSecrets = $bindable([]), isValid = $bindable(true), linkedSecretCandidates = undefined, - description = $bindable(undefined) + description = $bindable(undefined), + onSynced = undefined }: Props = $props() let schema = $state(emptySchema()) @@ -227,6 +230,7 @@ >No corresponding resource type found in your workspace for {resourceType}. Define the value in JSON directly

    + {/if} {#if notFound || viewJsonSchema} {#if !emptyString(error)} import { run } from 'svelte/legacy' - import { superadmin, userStore, workspaceStore } from '$lib/stores' + import { userStore, workspaceStore } from '$lib/stores' import IconedResourceType from './IconedResourceType.svelte' import { OauthService, ResourceService, VariableService, type TokenResponse, - type ResourceType, - JobService + type ResourceType } from '$lib/gen' import { emptyString, truncateRev, urlize } from '$lib/utils' import { createEventDispatcher, onDestroy } from 'svelte' @@ -31,9 +30,8 @@ import type { SchemaProperty } from '$lib/common' import Tooltip from './Tooltip.svelte' import TextInput from './text_input/TextInput.svelte' - import { usePromise } from '$lib/svelte5Utils.svelte' - import { pollJobResult } from './jobs/utils' import { sameTopDomainOrigin } from '$lib/cookies' + import SyncResourceTypes from './SyncResourceTypes.svelte' interface Props { step?: number @@ -135,6 +133,7 @@ let tokenUrl = $state('') let resourceTypeInfo: ResourceType | undefined = $state(undefined) + let resourceTypeNotFound = $state(false) let pathError = $state('') @@ -319,18 +318,24 @@ } async function getResourceTypeInfo() { - resourceTypeInfo = await ResourceService.getResourceType({ - workspace: effectiveWorkspace, - path: resourceType - }) - const props: Record = resourceTypeInfo?.schema?.['properties'] ?? {} - const newArgsKeys = Object.keys(props).filter((x) => props?.[x]?.type == 'string') ?? [] + try { + resourceTypeNotFound = false + resourceTypeInfo = await ResourceService.getResourceType({ + workspace: effectiveWorkspace, + path: resourceType + }) + const props: Record = resourceTypeInfo?.schema?.['properties'] ?? {} + const newArgsKeys = Object.keys(props).filter((x) => props?.[x]?.type == 'string') ?? [] - const passwords = newArgsKeys.filter((x) => { - return props?.[x]?.password - }) - if (linkedSecrets.length === 0) { - linkedSecrets = computeDefaultLinkedSecrets(resourceType, newArgsKeys, passwords) + const passwords = newArgsKeys.filter((x) => { + return props?.[x]?.password + }) + if (linkedSecrets.length === 0) { + linkedSecrets = computeDefaultLinkedSecrets(resourceType, newArgsKeys, passwords) + } + } catch (err) { + resourceTypeInfo = undefined + resourceTypeNotFound = true } } export async function next() { @@ -589,23 +594,6 @@ let filteredConnectsManual: { key: string; img?: string; instructions: string[] }[] = $state([]) let editScopes = $state(false) - - let hubRtSync = usePromise( - async () => { - let jobUuid = await JobService.runScriptByPath({ - workspace: 'admins', - path: 'u/admin/hub_sync', - requestBody: {} - }) - await pollJobResult(jobUuid, 'admins') - connectsManual = undefined - await loadResourceTypes() - connects = undefined - await loadConnects() - sendUserToast('Hub resource types sync completed') - }, - { loadInit: false } - ) {#if !express} @@ -722,20 +710,16 @@ {/each} {/if}
    - {#if $superadmin} - - {#if hubRtSync.status === 'error'} - - Error syncing resource types : {JSON.stringify(hubRtSync.error)} - - {/if} - {/if} +
    + { + connectsManual = undefined + await loadResourceTypes() + connects = undefined + await loadConnects() + }} + /> +
    {:else if step == 2 && manual}
    + {#if resourceTypeNotFound} +
    +

    + Resource type '{resourceType}' not found in your workspace +

    + +
    + {/if} {#key resourceTypeInfo} {/key}
    diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 3956cea7eb..416068bf8b 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -25,6 +25,7 @@ import Button from './common/button/Button.svelte' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' import ResourceGen from './copilot/ResourceGen.svelte' + import SyncResourceTypes from './SyncResourceTypes.svelte' interface Props { canSave?: boolean @@ -347,10 +348,13 @@ {:else} {#if !viewJsonSchema} -

    - No corresponding resource type found in your workspace for {resource_type}. Define the - value in JSON directly -

    +
    +

    + Resource type '{resource_type}' not found in your workspace +

    + +

    Define the value in JSON directly

    +
    {/if} {#if !emptyString(jsonError)} + import { superadmin } from '$lib/stores' + import { usePromise } from '$lib/svelte5Utils.svelte' + import { sendUserToast } from '$lib/toast' + import Button from './common/button/Button.svelte' + + interface Props { + onSynced?: () => void + } + + let { onSynced = undefined }: Props = $props() + + let hubRtSync = usePromise( + async () => { + const res = await fetch('/api/settings/sync_cached_resource_types', { method: 'POST' }) + if (!res.ok) { + const body = await res.text() + throw new Error(body || res.statusText) + } + sendUserToast('Hub resource types sync completed') + onSynced?.() + }, + { loadInit: false } + ) + + +{#if $superadmin} + + {#if hubRtSync.status === 'error'} + + Error syncing resource types: {hubRtSync.error?.message ?? JSON.stringify(hubRtSync.error)} + + {/if} +{/if} From 031766808945aefc926f0836d011c0b2a5d2243d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 14:33:20 +0000 Subject: [PATCH 030/153] fix: require admin for workspace encryption key export (#8523) Move the require_admin check from blocking the entire tarball export to only guarding the include_key=true path. Non-admins can still export tarballs for workspace sync/git, but only admins can export the raw workspace encryption key. Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-api/src/workspaces_export.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index d685d4c4cc..6c6c783ba7 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -388,8 +388,6 @@ pub(crate) async fn tarball_workspace( settings_version, }): Query, ) -> Result<([(HeaderName, String); 2], impl IntoResponse)> { - // require_admin(authed.is_admin, &authed.username)?; - tracing::info!( "tarball_workspace called for workspace {}: include_workspace_dependencies={:?}, skip_variables={:?}, skip_resources={:?}", w_id, @@ -1078,6 +1076,8 @@ pub(crate) async fn tarball_workspace( } if include_key.unwrap_or(false) { + require_admin(authed.is_admin, &authed.username)?; + let key = sqlx::query_scalar!( "SELECT key FROM workspace_key WHERE workspace_id = $1", &w_id From 8a32322c187ccc60ec7eafb61a9678f267a82282 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:38:29 +0100 Subject: [PATCH 031/153] fix: auto-generate datatable SDK reference for app mode system prompt (#8522) The app mode AI chat system prompt had hand-written datatable API docs that were missing methods (fetchOneScalar, execute, query). This adds datatable-specific extraction to generate.py so the prompt stays in sync with the actual TypeScript and Python client APIs. Co-authored-by: Claude Opus 4.6 (1M context) --- .../lib/components/copilot/chat/app/core.ts | 17 +- system_prompts/auto-generated/index.d.ts | 1 + system_prompts/auto-generated/index.ts | 8 + system_prompts/auto-generated/prompts.ts | 148 ++++++++++++ .../auto-generated/sdks/datatable-python.md | 68 ++++++ .../sdks/datatable-typescript.md | 76 +++++++ system_prompts/generate.py | 215 ++++++++++++++++++ 7 files changed, 521 insertions(+), 12 deletions(-) create mode 100644 system_prompts/auto-generated/sdks/datatable-python.md create mode 100644 system_prompts/auto-generated/sdks/datatable-typescript.md diff --git a/frontend/src/lib/components/copilot/chat/app/core.ts b/frontend/src/lib/components/copilot/chat/app/core.ts index 8fc88d4d16..c6a64a3e5a 100644 --- a/frontend/src/lib/components/copilot/chat/app/core.ts +++ b/frontend/src/lib/components/copilot/chat/app/core.ts @@ -10,6 +10,7 @@ import { createGetRunnableDetailsTool, type Tool } from '../shared' +import { getDatatableSdkReference } from '$system_prompts' import { aiChatManager } from '../AIChatManager.svelte' import type { ContextElement, @@ -842,38 +843,30 @@ For inline scripts, the code must have a \`main\` function as its entrypoint. Backend runnables should only perform **data operations** (SELECT, INSERT, UPDATE, DELETE) on **existing tables**. Never use CREATE TABLE, DROP TABLE, or ALTER TABLE inside runnables. -**TypeScript (Bun)**: +**TypeScript (Bun) example**: \`\`\`typescript import * as wmill from 'windmill-client'; export async function main(user_id: string) { const sql = ${datatableCall}; - - // Safe string interpolation (parameterized query) const user = await sql\`SELECT * FROM ${schemaPrefix}users WHERE id = \${user_id}\`.fetchOne(); return user; } \`\`\` -**Python**: +**Python example**: \`\`\`python import wmill def main(user_id: str): db = ${datatableCall} - - # Use positional arguments ($1, $2, etc.) user = db.query('SELECT * FROM ${schemaPrefix}users WHERE id = $1', user_id).fetch_one() return user \`\`\` -### Common Operations (for use in backend runnables) +### Datatable Client API Reference -- **Fetch all**: \`sql\`SELECT * FROM ${schemaPrefix}table\`.fetch()\` or \`db.query('SELECT * FROM ${schemaPrefix}table').fetch()\` -- **Fetch one**: \`.fetchOne()\` or \`.fetch_one()\` -- **Insert**: \`sql\`INSERT INTO ${schemaPrefix}table (col) VALUES (\${value})\`\` -- **Update**: \`sql\`UPDATE ${schemaPrefix}table SET col = \${value} WHERE id = \${id}\`\` -- **Delete**: \`sql\`DELETE FROM ${schemaPrefix}table WHERE id = \${id}\`\` +${getDatatableSdkReference()} ### Schema Modifications (DDL) - Use exec_datatable_sql tool ONLY diff --git a/system_prompts/auto-generated/index.d.ts b/system_prompts/auto-generated/index.d.ts index ab649a2090..4f2dd469ac 100644 --- a/system_prompts/auto-generated/index.d.ts +++ b/system_prompts/auto-generated/index.d.ts @@ -1,3 +1,4 @@ export * from './prompts'; export declare function getScriptPrompt(language: string): string; export declare function getFlowPrompt(): string; +export declare function getDatatableSdkReference(): string; diff --git a/system_prompts/auto-generated/index.ts b/system_prompts/auto-generated/index.ts index b283892470..b2c4bf0ec8 100644 --- a/system_prompts/auto-generated/index.ts +++ b/system_prompts/auto-generated/index.ts @@ -37,3 +37,11 @@ export function getFlowPrompt(): string { prompts.OPENFLOW_SCHEMA ].filter(Boolean).join('\n\n'); } + +// Helper to get datatable SDK reference for app mode +export function getDatatableSdkReference(): string { + return [ + prompts.DATATABLE_SDK_TYPESCRIPT, + prompts.DATATABLE_SDK_PYTHON + ].filter(Boolean).join('\n\n'); +} diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index d30a5be3eb..6eb870e62b 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1371,6 +1371,154 @@ async def parallel(items, fn, concurrency: Optional[int] = None) # offset: Message offset to commit (from event['offset']) def commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None +`; + +export const DATATABLE_SDK_TYPESCRIPT = `## TypeScript Datatable API (windmill-client) + +Import: \`import * as wmill from 'windmill-client'\` + +SQL statement object with query content, arguments, and execution methods +\`\`\`typescript +type SqlStatement = { + /** Raw SQL content with formatted arguments */ + content: string; + + /** Argument values keyed by parameter name */ + args: Record; + + /** + * Execute the SQL query and return results + * @param params - Optional parameters including result collection mode + * @returns Query results based on the result collection mode + */ + fetch( + params?: FetchParams // The union is for auto-completion + ): Promise>; + + /** + * Execute the SQL query and return only the first row + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOne( + params?: Omit, "resultCollection"> + ): Promise>; + + /** + * Execute the SQL query and return only the first row as a scalar value + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOneScalar( + params?: Omit< + FetchParams<"last_statement_first_row_scalar">, + "resultCollection" + > + ): Promise>; + + /** + * Execute the SQL query without fetching rows + * @param params - Optional parameters + */ + execute( + params?: Omit, "resultCollection"> + ): Promise; +}; +\`\`\` + +\`\`\`typescript +// Template tag function: sql\`SELECT * FROM table WHERE id = \${id}\`.fetch() +interface DatatableSqlTemplateFunction { + // Tagged template usage: + (strings: TemplateStringsArray, ...values: any[]): SqlStatement; + query(sql: string, ...params: any[]): SqlStatement; +}; +\`\`\` + +Create a SQL template function for PostgreSQL/datatable queries +@param name - Database/datatable name (default: "main") +@returns SQL template function for building parameterized queries +@example +let sql = wmill.datatable() +let name = 'Robin' +let age = 21 +await sql\` + SELECT * FROM friends + WHERE name = \${name} AND age = \${age}::int +\`.fetch() +\`\`\`typescript +function datatable(name: string = "main"): DatatableSqlTemplateFunction +\`\`\` +`; + +export const DATATABLE_SDK_PYTHON = `## Python Datatable API (wmill) + +Import: \`import wmill\` + +# Get a DataTable client for SQL queries. +# +# Args: +# name: Database name (default: "main") +# +# Returns: +# DataTableClient instance +def datatable(name: str = 'main') -> DataTableClient + +# Client for executing SQL queries against Windmill DataTables. +class DataTableClient: + # Initialize DataTableClient. + # + # Args: + # client: Windmill client instance + # name: DataTable name + def __init__(client: Windmill, name: str) + + # Execute a SQL query against the DataTable. + # + # Args: + # sql: SQL query string with $1, $2, etc. placeholders + # *args: Positional arguments to bind to query placeholders + # + # Returns: + # SqlQuery instance for fetching results + def query(sql: str, *args) -> SqlQuery + + +# Query result handler for DataTable and DuckLake queries. +class SqlQuery: + # Initialize SqlQuery. + # + # Args: + # sql: SQL query string + # fetch_fn: Function to execute the query + def __init__(sql: str, fetch_fn) + + # Execute query and fetch results. + # + # Args: + # result_collection: Optional result collection mode + # + # Returns: + # Query results + def fetch(result_collection: str | None = None) + + # Execute query and fetch first row of results. + # + # Returns: + # First row of query results + def fetch_one() + + # Execute query and fetch first row of results. Return result as a scalar value. + # + # Returns: + # First row of query result as a scalar value + def fetch_one_scalar() + + # Execute query and don't return any results. + # + def execute() + + `; export const OPENFLOW_SCHEMA = `## OpenFlow Schema diff --git a/system_prompts/auto-generated/sdks/datatable-python.md b/system_prompts/auto-generated/sdks/datatable-python.md new file mode 100644 index 0000000000..752019a9a3 --- /dev/null +++ b/system_prompts/auto-generated/sdks/datatable-python.md @@ -0,0 +1,68 @@ +## Python Datatable API (wmill) + +Import: `import wmill` + +# Get a DataTable client for SQL queries. +# +# Args: +# name: Database name (default: "main") +# +# Returns: +# DataTableClient instance +def datatable(name: str = 'main') -> DataTableClient + +# Client for executing SQL queries against Windmill DataTables. +class DataTableClient: + # Initialize DataTableClient. + # + # Args: + # client: Windmill client instance + # name: DataTable name + def __init__(client: Windmill, name: str) + + # Execute a SQL query against the DataTable. + # + # Args: + # sql: SQL query string with $1, $2, etc. placeholders + # *args: Positional arguments to bind to query placeholders + # + # Returns: + # SqlQuery instance for fetching results + def query(sql: str, *args) -> SqlQuery + + +# Query result handler for DataTable and DuckLake queries. +class SqlQuery: + # Initialize SqlQuery. + # + # Args: + # sql: SQL query string + # fetch_fn: Function to execute the query + def __init__(sql: str, fetch_fn) + + # Execute query and fetch results. + # + # Args: + # result_collection: Optional result collection mode + # + # Returns: + # Query results + def fetch(result_collection: str | None = None) + + # Execute query and fetch first row of results. + # + # Returns: + # First row of query results + def fetch_one() + + # Execute query and fetch first row of results. Return result as a scalar value. + # + # Returns: + # First row of query result as a scalar value + def fetch_one_scalar() + + # Execute query and don't return any results. + # + def execute() + + diff --git a/system_prompts/auto-generated/sdks/datatable-typescript.md b/system_prompts/auto-generated/sdks/datatable-typescript.md new file mode 100644 index 0000000000..0256515911 --- /dev/null +++ b/system_prompts/auto-generated/sdks/datatable-typescript.md @@ -0,0 +1,76 @@ +## TypeScript Datatable API (windmill-client) + +Import: `import * as wmill from 'windmill-client'` + +SQL statement object with query content, arguments, and execution methods +```typescript +type SqlStatement = { + /** Raw SQL content with formatted arguments */ + content: string; + + /** Argument values keyed by parameter name */ + args: Record; + + /** + * Execute the SQL query and return results + * @param params - Optional parameters including result collection mode + * @returns Query results based on the result collection mode + */ + fetch( + params?: FetchParams // The union is for auto-completion + ): Promise>; + + /** + * Execute the SQL query and return only the first row + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOne( + params?: Omit, "resultCollection"> + ): Promise>; + + /** + * Execute the SQL query and return only the first row as a scalar value + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOneScalar( + params?: Omit< + FetchParams<"last_statement_first_row_scalar">, + "resultCollection" + > + ): Promise>; + + /** + * Execute the SQL query without fetching rows + * @param params - Optional parameters + */ + execute( + params?: Omit, "resultCollection"> + ): Promise; +}; +``` + +```typescript +// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch() +interface DatatableSqlTemplateFunction { + // Tagged template usage: + (strings: TemplateStringsArray, ...values: any[]): SqlStatement; + query(sql: string, ...params: any[]): SqlStatement; +}; +``` + +Create a SQL template function for PostgreSQL/datatable queries +@param name - Database/datatable name (default: "main") +@returns SQL template function for building parameterized queries +@example +let sql = wmill.datatable() +let name = 'Robin' +let age = 21 +await sql` + SELECT * FROM friends + WHERE name = ${name} AND age = ${age}::int +`.fetch() +```typescript +function datatable(name: string = "main"): DatatableSqlTemplateFunction +``` diff --git a/system_prompts/generate.py b/system_prompts/generate.py index cf57a3cecb..034c94e20d 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -655,6 +655,202 @@ def generate_schema_files(cli_schemas: dict[str, dict]) -> dict[str, str]: return schema_yaml_content +# ============================================================================= +# Datatable SDK Extraction +# ============================================================================= + + +TS_SQL_UTILS_PATH = TS_SDK_DIR / "sqlUtils.ts" + + +def extract_datatable_ts_sdk() -> str: + """Extract datatable-specific type definitions from TypeScript SDK (sqlUtils.ts). + + Reads the source file and extracts the public API surface: + - SqlStatement type (fetch, fetchOne, fetchOneScalar, execute methods) + - DatatableSqlTemplateFunction interface (template tag + query method) + - datatable() function signature + """ + if not TS_SQL_UTILS_PATH.exists(): + print(f" Warning: sqlUtils.ts not found at {TS_SQL_UTILS_PATH}") + return '' + + content = TS_SQL_UTILS_PATH.read_text() + + md = "## TypeScript Datatable API (windmill-client)\n\n" + md += "Import: `import * as wmill from 'windmill-client'`\n\n" + + # Extract exported type/interface/function definitions from sqlUtils.ts + # We use extract_balanced to handle nested braces correctly + + # 1. Extract SqlStatement type + match = re.search(r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+type\s+SqlStatement\s*=\s*', content) + if match: + jsdoc_raw = match.group(1) + brace_start = content.index('{', match.end() - 1) + body, end = extract_balanced(content, brace_start, '{', '}') + if end != -1: + if jsdoc_raw: + md += clean_jsdoc(jsdoc_raw) + "\n" + md += "```typescript\n" + md += f"type SqlStatement = {{\n{_indent_body(body)}\n}};\n" + md += "```\n\n" + + # 2. Extract DatatableSqlTemplateFunction interface + match = re.search( + r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+interface\s+DatatableSqlTemplateFunction\s+extends\s+SqlTemplateFunction\s*', + content + ) + if match: + brace_start = content.index('{', match.end() - 1) + body, end = extract_balanced(content, brace_start, '{', '}') + if end != -1: + md += "```typescript\n" + md += "// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\n" + md += f"interface DatatableSqlTemplateFunction {{\n" + md += f" // Tagged template usage:\n" + md += f" (strings: TemplateStringsArray, ...values: any[]): SqlStatement;\n" + md += f"{_indent_body(body)}\n" + md += "};\n" + md += "```\n\n" + + # 3. Extract datatable() function + match = re.search( + r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+function\s+datatable\s*\(([^)]*)\)\s*:\s*(\S+)', + content + ) + if match: + jsdoc_raw, params, return_type = match.groups() + if jsdoc_raw: + md += clean_jsdoc(jsdoc_raw) + "\n" + md += "```typescript\n" + md += f"function datatable({params.strip()}): {return_type}\n" + md += "```\n" + + return md + + +def extract_datatable_py_sdk(py_content: str) -> str: + """Extract datatable-specific class/function definitions from Python SDK. + + Uses Python AST to extract: + - datatable() function + - DataTableClient class with query() method + - SqlQuery class with fetch(), fetch_one(), fetch_one_scalar(), execute() methods + """ + if not py_content: + return '' + + try: + tree = ast.parse(py_content) + except SyntaxError as e: + print(f" Warning: Could not parse Python SDK for datatable extraction: {e}") + return '' + + md = "## Python Datatable API (wmill)\n\n" + md += "Import: `import wmill`\n\n" + + # Target classes and the top-level datatable function + target_classes = {'DataTableClient', 'SqlQuery'} + + # 1. Extract datatable() top-level function + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == 'datatable': + docstring = ast.get_docstring(node) or '' + params = _format_py_params(node) + return_ann = f" -> {ast.unparse(node.returns)}" if node.returns else '' + if docstring: + for line in docstring.split('\n'): + md += f"# {line}\n" + md += f"def datatable({params}){return_ann}\n\n" + break + + # 2. Extract target classes with their public methods + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name in target_classes: + class_doc = ast.get_docstring(node) or '' + if class_doc: + for line in class_doc.split('\n'): + md += f"# {line}\n" + md += f"class {node.name}:\n" + + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if item.name.startswith('_') and item.name != '__init__': + continue + docstring = ast.get_docstring(item) or '' + params = _format_py_params(item, skip_self=True) + return_ann = f" -> {ast.unparse(item.returns)}" if item.returns else '' + async_prefix = 'async ' if isinstance(item, ast.AsyncFunctionDef) else '' + if docstring: + for line in docstring.split('\n'): + md += f" # {line}\n" + md += f" {async_prefix}def {item.name}({params}){return_ann}\n\n" + + md += "\n" + + return md + + +def _format_py_params(node: ast.FunctionDef, skip_self: bool = False) -> str: + """Format function parameters from AST node.""" + params = [] + args = node.args + num_defaults = len(args.defaults) + num_args = len(args.args) + + for i, arg in enumerate(args.args): + if skip_self and arg.arg == 'self': + continue + param_str = arg.arg + if arg.annotation: + param_str += f": {ast.unparse(arg.annotation)}" + default_idx = i - (num_args - num_defaults) + if default_idx >= 0: + default = args.defaults[default_idx] + param_str += f" = {ast.unparse(default)}" + params.append(param_str) + + if args.vararg: + vararg_str = f"*{args.vararg.arg}" + if args.vararg.annotation: + vararg_str += f": {ast.unparse(args.vararg.annotation)}" + params.append(vararg_str) + + for i, arg in enumerate(args.kwonlyargs): + param_str = arg.arg + if arg.annotation: + param_str += f": {ast.unparse(arg.annotation)}" + if args.kw_defaults[i]: + param_str += f" = {ast.unparse(args.kw_defaults[i])}" + params.append(param_str) + + if args.kwarg: + kwarg_str = f"**{args.kwarg.arg}" + if args.kwarg.annotation: + kwarg_str += f": {ast.unparse(args.kwarg.annotation)}" + params.append(kwarg_str) + + return ', '.join(params) + + +def _indent_body(body: str) -> str: + """Clean and re-indent a type body for readable output.""" + lines = body.strip().split('\n') + result = [] + for line in lines: + stripped = line.strip() + if stripped: + # Keep JSDoc comments and method signatures with consistent indentation + if not stripped.startswith('//') and not stripped.startswith('/*') and not stripped.startswith('*'): + result.append(f" {stripped}") + else: + result.append(f" {stripped}") + else: + result.append('') + return '\n'.join(result) + + # ============================================================================= # Skill Generation # ============================================================================= @@ -947,6 +1143,13 @@ def main(): (OUTPUT_SDKS_DIR / "python.md").write_text(py_sdk_md) print(f" Found {len(py_functions)} functions, {len(py_classes)} classes") + # Extract datatable-specific SDK docs (for app mode system prompt) + print("Extracting datatable SDK docs...") + datatable_ts_md = extract_datatable_ts_sdk() + datatable_py_md = extract_datatable_py_sdk(py_content) + (OUTPUT_SDKS_DIR / "datatable-typescript.md").write_text(datatable_ts_md) + (OUTPUT_SDKS_DIR / "datatable-python.md").write_text(datatable_py_md) + # Read base prompts print("Assembling complete prompts...") base_dir = SCRIPT_DIR / "base" @@ -1009,6 +1212,10 @@ def main(): 'SDK_TYPESCRIPT': ts_sdk_md, 'SDK_PYTHON': py_sdk_md, + # Datatable-specific SDK docs (for app mode) + 'DATATABLE_SDK_TYPESCRIPT': datatable_ts_md, + 'DATATABLE_SDK_PYTHON': datatable_py_md, + # Schema (raw YAML content) 'OPENFLOW_SCHEMA': openflow_content, @@ -1077,6 +1284,14 @@ export function getFlowPrompt(): string { prompts.OPENFLOW_SCHEMA ].filter(Boolean).join('\\n\\n'); } + +// Helper to get datatable SDK reference for app mode +export function getDatatableSdkReference(): string { + return [ + prompts.DATATABLE_SDK_TYPESCRIPT, + prompts.DATATABLE_SDK_PYTHON + ].filter(Boolean).join('\\n\\n'); +} """ (OUTPUT_GENERATED_DIR / "index.ts").write_text(index_content) From 4c8edd5e944d77ed2d41c2b87171c1115c0fdcdc Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 14:51:13 +0000 Subject: [PATCH 032/153] fix: restrict logout redirect to whitelisted domains (#8524) Co-authored-by: Claude Opus 4.5 --- backend/Cargo.lock | 1 + backend/windmill-api-users/Cargo.toml | 1 + backend/windmill-api-users/src/users.rs | 36 +++++++++++++++++-- frontend/src/lib/logoutRedirect.ts | 23 ++++++++++++ .../(logged)/user/(user)/logout/+page@.svelte | 7 +++- 5 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/logoutRedirect.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 34532bc173..c903490c8d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16386,6 +16386,7 @@ dependencies = [ "tokio", "tower-cookies", "tracing", + "url", "windmill-api-auth", "windmill-audit", "windmill-common", diff --git a/backend/windmill-api-users/Cargo.toml b/backend/windmill-api-users/Cargo.toml index e720b37eb7..13ab8143d8 100644 --- a/backend/windmill-api-users/Cargo.toml +++ b/backend/windmill-api-users/Cargo.toml @@ -34,3 +34,4 @@ time.workspace = true tokio.workspace = true tower-cookies.workspace = true tracing.workspace = true +url.workspace = true diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 8f7bec1398..75db0e2d24 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -49,13 +49,13 @@ use windmill_common::users::truncate_token; use windmill_common::users::COOKIE_NAME; use windmill_common::utils::paginate; use windmill_common::worker::CLOUD_HOSTED; -use windmill_common::BASE_URL; use windmill_common::{ auth::{get_folders_for_user, get_groups_for_user}, db::UserDB, error::{self, Error, JsonResult, Result}, utils::{not_found_if_none, rd_string, require_admin, Pagination, StripPath}, }; +use windmill_common::{BASE_URL, HUB_BASE_URL}; use windmill_git_sync::handle_deployment_metadata; const COOKIE_PATH: &str = "/"; @@ -577,12 +577,44 @@ async fn logout( } tx.commit().await?; if let Some(rd) = rd { - Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response()) + if is_valid_logout_redirect(&rd).await { + Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response()) + } else { + tracing::warn!("Blocked logout redirect to non-whitelisted URL: {}", rd); + Ok((StatusCode::OK, "logged out successfully".to_string()).into_response()) + } } else { Ok((StatusCode::OK, "logged out successfully".to_string()).into_response()) } } +async fn is_valid_logout_redirect(rd: &str) -> bool { + // Allow relative paths (same-origin redirects) + if rd.starts_with('/') && !rd.starts_with("//") { + return true; + } + let parsed = match url::Url::parse(rd) { + Ok(u) => u, + Err(_) => return false, + }; + let host: &str = match parsed.host_str() { + Some(h) => h, + None => return false, + }; + if host == "windmill.dev" || host.ends_with(".windmill.dev") { + return true; + } + let hub_url = HUB_BASE_URL.read().await.clone(); + if let Ok(hub_parsed) = url::Url::parse(&hub_url) { + if let Some(hub_host) = hub_parsed.host_str() { + if host == hub_host { + return true; + } + } + } + false +} + async fn whoami( Extension(db): Extension, Path(w_id): Path, diff --git a/frontend/src/lib/logoutRedirect.ts b/frontend/src/lib/logoutRedirect.ts new file mode 100644 index 0000000000..9b09880fd2 --- /dev/null +++ b/frontend/src/lib/logoutRedirect.ts @@ -0,0 +1,23 @@ +import { get } from 'svelte/store' +import { hubBaseUrlStore } from './stores' + +export function isValidLogoutRedirect(url: string): boolean { + if (url.startsWith('/') && !url.startsWith('//')) { + return true + } + try { + const parsed = new URL(url) + const host = parsed.hostname + if (host === 'windmill.dev' || host.endsWith('.windmill.dev')) { + return true + } + const hubBaseUrl = get(hubBaseUrlStore) + try { + const hubHost = new URL(hubBaseUrl).hostname + if (host === hubHost) { + return true + } + } catch {} + } catch {} + return false +} diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte index e328c37f5a..75e31a9a00 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state' import CenteredModal from '$lib/components/CenteredModal.svelte' import { clearUser } from '$lib/logout' + import { isValidLogoutRedirect } from '$lib/logoutRedirect' import { userStore } from '$lib/stores' import { onMount } from 'svelte' @@ -29,7 +30,11 @@ return } - window.location.href = rd ?? '/user/login' + if (rd && isValidLogoutRedirect(rd)) { + window.location.href = rd + } else { + window.location.href = '/user/login' + } }) From c28314f424ea0e04b86565ce88e6c91e0df1a0cf Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 25 Mar 2026 16:13:04 +0100 Subject: [PATCH 033/153] feat: runner groups for shared-process multi-script dedicated workers (#8434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add runner groups for shared-process multi-script dedicated workers Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: unify dedicated worker and runner group wrappers into single multi-script wrapper Replace per-language single-script wrappers with the unified load/exec/exec_preprocess/end protocol. Each start_worker() now writes scripts to scripts// and uses generate_multi_script_wrapper(). handle_dedicated_process() sends load: on start and exec: per job instead of raw JSON args. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: merge runner groups into dedicated workers with inline arg metadata Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to match EE branch Co-Authored-By: Claude Opus 4.6 (1M context) * fix: gate EE-only functions behind cfg(feature = "private") to fix OSS dead_code errors Co-Authored-By: Claude Opus 4.6 (1M context) * feat: auto-detect runner groups from workspace dependency annotations - New endpoint GET /scripts/list_dedicated_with_deps: returns dedicated scripts with parsed workspace dependency names from content annotations - Frontend: show dep badges in DedicatedWorkersSelector with links to workspace settings, warn when referenced dep doesn't exist, group scripts sharing deps into "Shared runner" sections - Remove manual "Runner groups" tab and RunnerGroupSelector component - Remove runner_groups from WorkerConfigOpt/WorkerConfig (auto-detected) - Fix Node.js single dedicated workers: transpile main.ts -> main.js via Bun.build so the multi-script wrapper's dynamic import() works under Node - Add package.json with type:module in scripts dir to silence Node warning Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: unify dedicated worker wrappers with baked-in codegen and routing Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * test: add e2e tests for multi-script dedicated worker routing (bun, deno, python) Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove dead generate_dedicated_worker_wrapper function Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add dependency installation to runner groups + make dep functions pub(crate) Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * fix: prevent bun loader from intercepting absolute paths within cwd When a plugin's onResolve returns an absolute path, Bun re-invokes the resolver with that path. The loader was then routing it through the remote URL resolver, breaking runner group script imports. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use _wm_ prefix for runner group scripts to avoid bun loader interception Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract DENO_UNSTABLE_ARGS constant to avoid repeating flags Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate system prompts Co-Authored-By: Claude Opus 4.6 (1M context) * fix: gate private-only exports behind cfg(feature = "private") for OSS build Co-Authored-By: Claude Opus 4.6 (1M context) * fix: move format strings before handle_dedicated_process to fix lifetime Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate sqlx offline cache Co-Authored-By: Claude Opus 4.6 (1M context) * fix sqlx * fix: skip empty lines in deno e2e tests (double newline from console.log + '\n') Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use dict() instead of {{}} in python wrapper to avoid set literal {{{{}}}} in format!() produces {{}} which Python interprets as an empty set, not a dict. Use dict() which is unambiguous. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove deno from runner groups and associated tests Deno resolves dependencies at runtime via URLs/import maps, so there's no shared node_modules/pip install to benefit from runner groups. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: revert deno wrapper to inline old-style with exec: protocol Since deno doesn't support runner groups, the unified multi-script wrapper is unnecessary. Reverted to the old inline wrapper from main but adapted to use the exec:: protocol. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract deno wrapper into reusable function and add e2e tests Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use codebase presence (not nodejs annotation) to determine wrapper import extension On main, codebase scripts import ./main.js (pre-bundled JS). The wrapper_ext was incorrectly based on annotation.nodejs. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: improve dedicated workers UI - combine lists, better badges, tooltips - Merge shared runners section with selected tags into one unified list - Move language tag to right side of selector for alignment - Change dep badge color from dark-gray to indigo - Add tooltip on yellow warning badge explaining missing workspace dep Co-Authored-By: Claude Opus 4.6 (1M context) * feat: group shared runners visually in dedicated workers list - Runner groups shown with a header (Shared runner · language · dep badge) - Scripts in the same group nested under the header - Standalone scripts/flows shown after groups - Used Svelte snippet for reusable tag row rendering Co-Authored-By: Claude Opus 4.6 (1M context) * fix: improve visual separation between shared runner groups and standalone items Co-Authored-By: Claude Opus 4.6 (1M context) * feat: give standalone runners same header style as shared runners - Each standalone script/flow gets its own header row with bg-surface-secondary - Header shows "Dedicated runner" / "Flow runner" label, dep link, language badge - Shared runner header: swapped language and dep badge positions - Dep shown as inline link instead of badge in headers for cleaner look Co-Authored-By: Claude Opus 4.6 (1M context) * feat: inline standalone runner path in header, language badge on right edge, no max height - Standalone items: path shown directly in header row (no sub-row) - Language badge placed after flex-1 spacer (right-aligned) - Removed max-h-64 overflow constraint from the list Co-Authored-By: Claude Opus 4.6 (1M context) * feat: consistent badges across runner list - dep+language on right, depBadge snippet - Shared runner scripts: show (workspace) and language badge on right - Standalone items: dep badges and language badge on right (after flex-1) - Shared runner header: dep badge and language badge on right - Extract depBadge snippet to deduplicate dep badge rendering - Picker selector also uses depBadge snippet Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show language badge on standalone items, hide from shared runner sub-items - Fetch script language from API when not available from workspace deps - Hide dep+language badges from tagRow when script is inside a runner group (already shown in the group header) - Standalone items now always show language badge Co-Authored-By: Claude Opus 4.6 (1M context) * fix: differentiate badge colors - gray for language, indigo for workspace deps Matches codebase convention: gray for metadata (like script hashes), indigo for linkable features/entities. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use transparent (bordered) badge for language - visible on all backgrounds Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use gray badge for language everywhere Co-Authored-By: Claude Opus 4.6 (1M context) * fix: revert skills.ts and AI files, add _wm_ exclusion to Windows loader - Revert cli/src/guidance/skills.ts to main (not our change) - Revert AI provider formatting changes (not our change) - Add _wm_ prefix exclusion to loader.bun.windows.js filterResolve Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update ee-repo-ref and regenerate system prompts after merge Co-Authored-By: Claude Opus 4.6 (1M context) * perf: use DISTINCT ON in list_dedicated_with_deps to dedup at DB level Avoids fetching all script versions and deduplicating in Rust. Addresses PR review feedback. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use sqlx query! macro for list_dedicated_with_deps and regenerate cache Co-Authored-By: Claude Opus 4.6 (1M context) * fix: dedicated worker review fixes and test coverage - Fix Python relative imports in dedicated workers (write loader.py, add import loader to wrapper when needed) - Move Python colon parsing inside try/except to prevent crashes on malformed stdin - Add indexOf guard in Bun/Deno wrappers for malformed protocol messages - Add stderr logging for unrecognized stdin commands in all wrappers - Remove asyncio handling from Python wrapper (consistent with normal path) - Add exec_preprocess protocol tests for Bun, Deno, and Python - Add argument transformation tests (dates, bytes, kwargs, sentinel) - Add relative import detection test for Python wrapper - Add PreprocessedArgs variant to DedicatedWorkerResult test helper Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove symlink from git and gate has_relative_imports behind private feature Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update ee-repo-ref for dedicated_worker_ee.rs changes Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add mixed exec+preprocess test to use ProtocolCmd::Exec variant Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove hanging deno missing-preprocessor test The Deno wrapper only generates the exec_preprocess handler when the script has a preprocessor function. Without one, the message is unrecognized and the test hangs reading stdout. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 182943e5ad9bf2a905ccdf07d4e346437fb329a9 This commit updates the EE repository reference after PR #466 was merged in windmill-ee-private. Previous ee-repo-ref: 995f701fe3754be6260fc6b679e5de8fc636e68a New ee-repo-ref: 182943e5ad9bf2a905ccdf07d4e346437fb329a9 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...6be1bceb79c82e7b5542f17b23b6d70cc02d6.json | 104 +++ ...7d5ee9490240a627b20a1037444845e39c5f.json} | 4 +- backend/ee-repo-ref.txt | 2 +- backend/tests/bun_jobs.rs | 769 +++++++++++++++++- backend/tests/python_jobs.rs | 461 +++++++++++ backend/windmill-api-scripts/src/scripts.rs | 69 +- backend/windmill-api/openapi.yaml | 54 ++ backend/windmill-common/src/ai_google.rs | 40 +- backend/windmill-common/src/worker.rs | 12 +- backend/windmill-test-utils/src/lib.rs | 10 + backend/windmill-worker/loader.bun.js | 2 +- backend/windmill-worker/loader.bun.windows.js | 2 +- backend/windmill-worker/src/bun_executor.rs | 360 +++++--- backend/windmill-worker/src/deno_executor.rs | 284 +++---- backend/windmill-worker/src/lib.rs | 19 +- .../windmill-worker/src/python_executor.rs | 499 +++++++++--- .../DedicatedWorkersSelector.svelte | 412 ++++++++-- 17 files changed, 2590 insertions(+), 513 deletions(-) create mode 100644 backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json rename backend/.sqlx/{query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json => query-fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f.json} (50%) diff --git a/backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json b/backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json new file mode 100644 index 0000000000..a0d7f223d0 --- /dev/null +++ b/backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json @@ -0,0 +1,104 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script\n WHERE workspace_id = $1\n AND archived = false\n AND dedicated_worker = true\n AND language = ANY($2::SCRIPT_LANG[])\n ORDER BY path, created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "language: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "content", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "script_lang[]", + "kind": { + "Array": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby" + ] + } + } + } + } + } + } + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6" +} diff --git a/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json b/backend/.sqlx/query-fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f.json similarity index 50% rename from backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json rename to backend/.sqlx/query-fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f.json index e778c17bf6..50cf586c79 100644 --- a/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json +++ b/backend/.sqlx/query-fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f.json @@ -1,6 +1,6 @@ { "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)", + "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": { @@ -15,5 +15,5 @@ }, "nullable": [] }, - "hash": "d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90" + "hash": "fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 7d86a6114e..263ec1de9a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc +182943e5ad9bf2a905ccdf07d4e346437fb329a9 diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 2edfef8989..7183cf6e10 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -891,25 +891,34 @@ mod dedicated_worker_protocol { use std::process::{Command, Stdio}; use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult}; use windmill_worker::{ - build_loader, generate_dedicated_worker_wrapper, LoaderMode, BUN_DEDICATED_WORKER_ARGS, - BUN_PATH, NODE_BIN_PATH, + build_loader, compute_ts_codegen, generate_multi_script_wrapper, LoaderMode, TsScriptEntry, + BUN_DEDICATED_WORKER_ARGS, BUN_PATH, NODE_BIN_PATH, }; + const TEST_SCRIPT_PATH: &str = "f/test/script"; + /// Creates test worker files and optionally bundles for Node.js (like production) /// Returns the path to the wrapper file to execute fn create_test_worker_files( dir: &std::path::Path, script: &str, - arg_names: &[&str], bundle_for_node: bool, ) -> std::path::PathBuf { let dir_str = dir.to_str().unwrap(); + // Write main.ts at root (like production single-script) std::fs::write(dir.join("main.ts"), script).unwrap(); + let codegen = compute_ts_codegen(script); + let ext = if bundle_for_node { "js" } else { "ts" }; + let scripts = [TsScriptEntry { + import_name: "main", + original_path: TEST_SCRIPT_PATH, + codegen: &codegen, + }]; + let wrapper = generate_multi_script_wrapper(&scripts, ext); + if bundle_for_node { - // For Node.js: bundle to JavaScript first (like production's build_loader with LoaderMode::Node) - let wrapper = generate_dedicated_worker_wrapper(arg_names, "./main.js", None, None); - std::fs::write(dir.join("wrapper.mjs"), wrapper).unwrap(); + std::fs::write(dir.join("wrapper.mjs"), &wrapper).unwrap(); // Use the exact same build_loader function as production tokio::runtime::Runtime::new() @@ -919,7 +928,7 @@ mod dedicated_worker_protocol { "http://localhost:8000", "test_token", "test-workspace", - "f/test/script", + TEST_SCRIPT_PATH, LoaderMode::Node, &None, )) @@ -945,10 +954,8 @@ mod dedicated_worker_protocol { std::fs::rename(&bundled_path, &output_path).unwrap(); output_path } else { - // For Bun: use TypeScript directly (like production) - let wrapper = generate_dedicated_worker_wrapper(arg_names, "./main.ts", None, None); let wrapper_path = dir.join("wrapper.mjs"); - std::fs::write(&wrapper_path, wrapper).unwrap(); + std::fs::write(&wrapper_path, &wrapper).unwrap(); wrapper_path } } @@ -957,14 +964,12 @@ mod dedicated_worker_protocol { fn run_worker_test( runtime: &str, script: &str, - arg_names: &[&str], jobs: Vec, ) -> Vec> { let temp_dir = tempfile::tempdir().unwrap(); // Create files and get the wrapper path (bundled for node, raw for bun) - let wrapper_path = - create_test_worker_files(temp_dir.path(), script, arg_names, runtime == "node"); + let wrapper_path = create_test_worker_files(temp_dir.path(), script, runtime == "node"); let wrapper_str = wrapper_path.to_str().unwrap(); // Build args matching production behavior @@ -1008,7 +1013,8 @@ mod dedicated_worker_protocol { let mut results = Vec::new(); for job_args in jobs { - writeln!(stdin, "{}", job_args.to_string()).unwrap(); + // Protocol: exec:: + writeln!(stdin, "exec:{}:{}", TEST_SCRIPT_PATH, job_args.to_string()).unwrap(); stdin.flush().unwrap(); let mut response = String::new(); @@ -1043,12 +1049,7 @@ export function main(x: number, y: number): number { return x + y; } "#; - let results = run_worker_test( - "node", - script, - &["x", "y"], - vec![serde_json::json!({"x": 5, "y": 3})], - ); + let results = run_worker_test("node", script, vec![serde_json::json!({"x": 5, "y": 3})]); assert_eq!(results.len(), 1); assert_eq!(results[0], Ok(serde_json::json!(8))); @@ -1062,7 +1063,7 @@ export function main(n: number): number { } "#; let jobs: Vec = (1..=5).map(|i| serde_json::json!({"n": i})).collect(); - let results = run_worker_test("node", script, &["n"], jobs); + let results = run_worker_test("node", script, jobs); assert_eq!(results.len(), 5); for (i, result) in results.iter().enumerate() { @@ -1081,7 +1082,6 @@ export function main(msg: string): never { let results = run_worker_test( "node", script, - &["msg"], vec![serde_json::json!({"msg": "test error"})], ); @@ -1099,12 +1099,7 @@ export function main(x: number, y: number): number { return x + y; } "#; - let results = run_worker_test( - "bun", - script, - &["x", "y"], - vec![serde_json::json!({"x": 5, "y": 3})], - ); + let results = run_worker_test("bun", script, vec![serde_json::json!({"x": 5, "y": 3})]); assert_eq!(results.len(), 1); assert_eq!(results[0], Ok(serde_json::json!(8))); @@ -1118,7 +1113,7 @@ export function main(n: number): number { } "#; let jobs: Vec = (1..=5).map(|i| serde_json::json!({"n": i})).collect(); - let results = run_worker_test("bun", script, &["n"], jobs); + let results = run_worker_test("bun", script, jobs); assert_eq!(results.len(), 5); for (i, result) in results.iter().enumerate() { @@ -1137,7 +1132,6 @@ export function main(msg: string): never { let results = run_worker_test( "bun", script, - &["msg"], vec![serde_json::json!({"msg": "test error"})], ); @@ -1145,6 +1139,721 @@ export function main(msg: string): never { assert!(results[0].is_err()); assert_eq!(results[0], Err("test error".to_string())); } + + // ==================== Multi-Script (Runner Group) Tests ==================== + + /// Job to send to a specific script in a multi-script wrapper + struct MultiScriptJob { + script_path: String, + args: serde_json::Value, + } + + /// Creates a multi-script wrapper with multiple scripts as flat files, returns the wrapper path + fn create_multi_script_worker_files( + dir: &std::path::Path, + scripts: &[(&str, &str)], // (original_path, script_content) + ) -> std::path::PathBuf { + let mut entries_data = Vec::new(); + for (path, content) in scripts { + let safe_name = format!("_wm_{}", path.replace('/', "__")); + std::fs::write(dir.join(format!("{safe_name}.ts")), content).unwrap(); + entries_data.push((safe_name, path.to_string(), compute_ts_codegen(content))); + } + + let entries: Vec> = entries_data + .iter() + .map(|(safe, path, cg)| TsScriptEntry { + import_name: safe.as_str(), + original_path: path.as_str(), + codegen: cg, + }) + .collect(); + + let wrapper = generate_multi_script_wrapper(&entries, "ts"); + let wrapper_path = dir.join("wrapper.mjs"); + std::fs::write(&wrapper_path, &wrapper).unwrap(); + wrapper_path + } + + /// Helper to run a multi-script dedicated worker test + fn run_multi_script_worker_test( + scripts: &[(&str, &str)], + jobs: Vec, + ) -> Vec> { + let temp_dir = tempfile::tempdir().unwrap(); + let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts); + let wrapper_str = wrapper_path.to_str().unwrap(); + + let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec(); + cmd_args.push(wrapper_str); + + let mut child = Command::new(BUN_PATH.as_str()) + .args(cmd_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn worker process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + // Wait for "start" signal + let mut start_line = String::new(); + reader.read_line(&mut start_line).unwrap(); + assert_eq!( + parse_dedicated_worker_line(start_line.trim()), + DedicatedWorkerResult::Start, + "Expected 'start', got: {}", + start_line.trim() + ); + + let mut results = Vec::new(); + + for job in &jobs { + writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap(); + stdin.flush().unwrap(); + + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + + match parse_dedicated_worker_line(response.trim()) { + DedicatedWorkerResult::Success(value) => results.push(Ok(value)), + DedicatedWorkerResult::Error(err) => { + let msg = err["message"] + .as_str() + .unwrap_or("Unknown error") + .to_string(); + results.push(Err(msg)); + } + other => panic!("Unexpected response: {:?}", other), + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + + results + } + + #[test] + fn test_multi_script_routing_basic() { + let script_add = r#" +export function main(a: number, b: number): number { + return a + b; +} +"#; + let script_mul = r#" +export function main(x: number, y: number): number { + return x * y; +} +"#; + let results = run_multi_script_worker_test( + &[("f/math/add", script_add), ("f/math/mul", script_mul)], + vec![ + MultiScriptJob { + script_path: "f/math/add".to_string(), + args: serde_json::json!({"a": 3, "b": 4}), + }, + MultiScriptJob { + script_path: "f/math/mul".to_string(), + args: serde_json::json!({"x": 5, "y": 6}), + }, + // Route back to add + MultiScriptJob { + script_path: "f/math/add".to_string(), + args: serde_json::json!({"a": 10, "b": 20}), + }, + ], + ); + + assert_eq!(results.len(), 3); + assert_eq!(results[0], Ok(serde_json::json!(7))); // 3 + 4 + assert_eq!(results[1], Ok(serde_json::json!(30))); // 5 * 6 + assert_eq!(results[2], Ok(serde_json::json!(30))); // 10 + 20 + } + + #[test] + fn test_multi_script_interleaved_jobs() { + let script_upper = r#" +export function main(s: string): string { + return s.toUpperCase(); +} +"#; + let script_len = r#" +export function main(s: string): number { + return s.length; +} +"#; + let results = run_multi_script_worker_test( + &[("f/str/upper", script_upper), ("f/str/len", script_len)], + vec![ + MultiScriptJob { + script_path: "f/str/upper".to_string(), + args: serde_json::json!({"s": "hello"}), + }, + MultiScriptJob { + script_path: "f/str/len".to_string(), + args: serde_json::json!({"s": "hello"}), + }, + MultiScriptJob { + script_path: "f/str/upper".to_string(), + args: serde_json::json!({"s": "world"}), + }, + MultiScriptJob { + script_path: "f/str/len".to_string(), + args: serde_json::json!({"s": "ab"}), + }, + ], + ); + + assert_eq!(results.len(), 4); + assert_eq!(results[0], Ok(serde_json::json!("HELLO"))); + assert_eq!(results[1], Ok(serde_json::json!(5))); + assert_eq!(results[2], Ok(serde_json::json!("WORLD"))); + assert_eq!(results[3], Ok(serde_json::json!(2))); + } + + #[test] + fn test_multi_script_unknown_path_error() { + let script = r#" +export function main(x: number): number { + return x; +} +"#; + let results = run_multi_script_worker_test( + &[("f/known", script)], + vec![MultiScriptJob { + script_path: "f/unknown".to_string(), + args: serde_json::json!({"x": 1}), + }], + ); + + assert_eq!(results.len(), 1); + assert!(results[0].is_err()); + assert!(results[0] + .as_ref() + .unwrap_err() + .contains("Script not found")); + } + + #[test] + fn test_multi_script_error_doesnt_break_other_scripts() { + let script_ok = r#" +export function main(x: number): number { + return x * 2; +} +"#; + let script_err = r#" +export function main(msg: string): never { + throw new Error(msg); +} +"#; + let results = run_multi_script_worker_test( + &[("f/ok", script_ok), ("f/err", script_err)], + vec![ + MultiScriptJob { + script_path: "f/ok".to_string(), + args: serde_json::json!({"x": 5}), + }, + MultiScriptJob { + script_path: "f/err".to_string(), + args: serde_json::json!({"msg": "boom"}), + }, + // Should still work after error in other script + MultiScriptJob { + script_path: "f/ok".to_string(), + args: serde_json::json!({"x": 10}), + }, + ], + ); + + assert_eq!(results.len(), 3); + assert_eq!(results[0], Ok(serde_json::json!(10))); + assert!(results[1].is_err()); + assert_eq!(results[1], Err("boom".to_string())); + assert_eq!(results[2], Ok(serde_json::json!(20))); + } + + // ==================== exec_preprocess Tests ==================== + + /// Raw protocol command to send to a dedicated worker + enum ProtocolCmd { + Exec { path: String, args: serde_json::Value }, + ExecPreprocess { path: String, args: serde_json::Value }, + } + + /// Run a multi-script worker test with raw protocol commands, returning all protocol lines + fn run_raw_protocol_test( + scripts: &[(&str, &str)], + commands: Vec, + ) -> Vec { + let temp_dir = tempfile::tempdir().unwrap(); + let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts); + let wrapper_str = wrapper_path.to_str().unwrap(); + + let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec(); + cmd_args.push(wrapper_str); + + let mut child = Command::new(BUN_PATH.as_str()) + .args(cmd_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn worker process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + let mut start_line = String::new(); + reader.read_line(&mut start_line).unwrap(); + assert_eq!( + parse_dedicated_worker_line(start_line.trim()), + DedicatedWorkerResult::Start, + ); + + let mut results = Vec::new(); + + for cmd in &commands { + let line = match cmd { + ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args), + ProtocolCmd::ExecPreprocess { path, args } => { + format!("exec_preprocess:{}:{}", path, args) + } + }; + writeln!(stdin, "{}", line).unwrap(); + stdin.flush().unwrap(); + + // exec_preprocess produces 2 response lines (preprocessed_args + success/error) + // exec produces 1 response line (success/error) + let expected_lines = match cmd { + ProtocolCmd::ExecPreprocess { .. } => 2, + ProtocolCmd::Exec { .. } => 1, + }; + + for _ in 0..expected_lines { + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + let parsed = parse_dedicated_worker_line(response.trim()); + // If it's an error, stop reading more lines for this command + if matches!(parsed, DedicatedWorkerResult::Error(_)) { + results.push(parsed); + break; + } + results.push(parsed); + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + + results + } + + #[test] + fn test_bun_exec_preprocess() { + let script = r#" +export function preprocessor(x: number) { + return { x: x * 10 }; +} +export function main(x: number): number { + return x + 1; +} +"#; + let results = run_raw_protocol_test( + &[("f/test/pre", script)], + vec![ProtocolCmd::ExecPreprocess { + path: "f/test/pre".to_string(), + args: serde_json::json!({"x": 5}), + }], + ); + // Should get preprocessed_args then success + assert_eq!(results.len(), 2); + assert_eq!( + results[0], + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50})) + ); + // main(50) => 51 + assert_eq!( + results[1], + DedicatedWorkerResult::Success(serde_json::json!(51)) + ); + } + + #[test] + fn test_bun_exec_preprocess_missing_preprocessor() { + let script = r#" +export function main(x: number): number { + return x; +} +"#; + let results = run_raw_protocol_test( + &[("f/test/nopre", script)], + vec![ProtocolCmd::ExecPreprocess { + path: "f/test/nopre".to_string(), + args: serde_json::json!({"x": 5}), + }], + ); + assert_eq!(results.len(), 1); + assert!(matches!(results[0], DedicatedWorkerResult::Error(_))); + } + + #[test] + fn test_bun_exec_preprocess_then_exec() { + let script = r#" +export function preprocessor(x: number) { + return { x: x * 2 }; +} +export function main(x: number): number { + return x + 100; +} +"#; + let results = run_raw_protocol_test( + &[("f/test/mixed", script)], + vec![ + ProtocolCmd::ExecPreprocess { + path: "f/test/mixed".to_string(), + args: serde_json::json!({"x": 5}), + }, + ProtocolCmd::Exec { + path: "f/test/mixed".to_string(), + args: serde_json::json!({"x": 7}), + }, + ], + ); + // preprocess: preprocessor(5) => {"x":10}, main(10) => 110 + // exec: main(7) => 107 + assert_eq!(results.len(), 3); + assert_eq!( + results[0], + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10})) + ); + assert_eq!( + results[1], + DedicatedWorkerResult::Success(serde_json::json!(110)) + ); + assert_eq!( + results[2], + DedicatedWorkerResult::Success(serde_json::json!(107)) + ); + } + + // ==================== Argument Transformation Tests ==================== + + #[test] + fn test_bun_date_arg_transformation() { + let script = r#" +export function main(d: Date): string { + return d instanceof Date ? d.toISOString() : typeof d; +} +"#; + let results = run_worker_test( + "bun", + script, + vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})], + ); + assert_eq!(results.len(), 1); + assert_eq!( + results[0], + Ok(serde_json::json!("2024-01-15T10:30:00.000Z")) + ); + } + + #[test] + fn test_bun_null_and_undefined_args() { + let script = r#" +export function main(x?: number): string { + return x === null ? "null" : x === undefined ? "undefined" : String(x); +} +"#; + let results = run_worker_test( + "bun", + script, + vec![ + serde_json::json!({"x": null}), + serde_json::json!({"x": 42}), + serde_json::json!({}), + ], + ); + assert_eq!(results.len(), 3); + assert_eq!(results[0], Ok(serde_json::json!("null"))); + assert_eq!(results[1], Ok(serde_json::json!("42"))); + // Missing arg should be undefined + assert_eq!(results[2], Ok(serde_json::json!("undefined"))); + } +} + +// ============================================================================ +// Deno Dedicated Worker Protocol Tests +// ============================================================================ + +mod dedicated_worker_protocol_deno { + use std::io::{BufRead, BufReader, Write}; + use std::process::{Command, Stdio}; + use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult}; + use windmill_worker::{generate_deno_dedicated_worker_wrapper, DENO_PATH}; + + const TEST_SCRIPT_PATH: &str = "f/test/script"; + + fn run_deno_worker_test( + script: &str, + jobs: Vec, + ) -> Vec> { + let temp_dir = tempfile::tempdir().unwrap(); + std::fs::write(temp_dir.path().join("main.ts"), script).unwrap(); + + let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap(); + std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap(); + + let mut child = Command::new(DENO_PATH.as_str()) + .args([ + "run", + "--no-check", + "--unstable-unsafe-proto", + "--unstable-bare-node-builtins", + "-A", + "wrapper.ts", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn deno process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + // Wait for "start" — deno outputs 'start\n' via console.log which adds + // its own newline, producing double newlines. Skip empty lines. + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line.trim().is_empty() { + continue; + } + assert_eq!( + parse_dedicated_worker_line(line.trim()), + DedicatedWorkerResult::Start, + "Expected 'start', got: {}", + line.trim() + ); + break; + } + + let mut results = Vec::new(); + for job_args in jobs { + writeln!(stdin, "exec:{}:{}", TEST_SCRIPT_PATH, job_args.to_string()).unwrap(); + stdin.flush().unwrap(); + + loop { + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + let trimmed = response.trim(); + if trimmed.is_empty() { + continue; + } + match parse_dedicated_worker_line(trimmed) { + DedicatedWorkerResult::Success(value) => results.push(Ok(value)), + DedicatedWorkerResult::Error(err) => { + let msg = err["message"] + .as_str() + .unwrap_or("Unknown error") + .to_string(); + results.push(Err(msg)); + } + other => panic!("Unexpected response: {:?}", other), + } + break; + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + results + } + + #[test] + fn test_deno_dedicated_worker_simple() { + let script = r#" +export function main(x: number, y: number): number { + return x + y; +} +"#; + let results = run_deno_worker_test(script, vec![serde_json::json!({"x": 5, "y": 3})]); + assert_eq!(results.len(), 1); + assert_eq!(results[0], Ok(serde_json::json!(8))); + } + + #[test] + fn test_deno_dedicated_worker_multiple_jobs() { + let script = r#" +export function main(n: number): number { + return n * 2; +} +"#; + let jobs: Vec = (1..=5).map(|i| serde_json::json!({"n": i})).collect(); + let results = run_deno_worker_test(script, jobs); + assert_eq!(results.len(), 5); + for (i, result) in results.iter().enumerate() { + assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64))); + } + } + + #[test] + fn test_deno_dedicated_worker_error() { + let script = r#" +export function main(msg: string): never { + throw new Error(msg); +} +"#; + let results = run_deno_worker_test(script, vec![serde_json::json!({"msg": "test error"})]); + assert_eq!(results.len(), 1); + assert!(results[0].is_err()); + assert_eq!(results[0], Err("test error".to_string())); + } + + // ==================== exec_preprocess Tests ==================== + + /// Run a raw deno protocol test, reading all output lines per command + fn run_deno_raw_protocol_test( + script: &str, + commands: Vec<(&str, serde_json::Value)>, // ("exec" or "exec_preprocess", args) + ) -> Vec { + let temp_dir = tempfile::tempdir().unwrap(); + std::fs::write(temp_dir.path().join("main.ts"), script).unwrap(); + + let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap(); + std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap(); + + let mut child = Command::new(DENO_PATH.as_str()) + .args([ + "run", + "--no-check", + "--unstable-unsafe-proto", + "--unstable-bare-node-builtins", + "-A", + "wrapper.ts", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn deno process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + // Wait for start, skip empty lines + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line.trim().is_empty() { + continue; + } + assert_eq!( + parse_dedicated_worker_line(line.trim()), + DedicatedWorkerResult::Start, + ); + break; + } + + let mut results = Vec::new(); + + for (cmd, args) in &commands { + writeln!(stdin, "{}:{}:{}", cmd, TEST_SCRIPT_PATH, args).unwrap(); + stdin.flush().unwrap(); + + let expected_lines = if *cmd == "exec_preprocess" { 2 } else { 1 }; + + for _ in 0..expected_lines { + loop { + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + if response.trim().is_empty() { + continue; + } + let parsed = parse_dedicated_worker_line(response.trim()); + if matches!(parsed, DedicatedWorkerResult::Error(_)) { + results.push(parsed); + break; + } + results.push(parsed); + break; + } + // If last result was an error, don't read more lines for this command + if matches!(results.last(), Some(DedicatedWorkerResult::Error(_))) { + break; + } + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + + results + } + + #[test] + fn test_deno_exec_preprocess() { + let script = r#" +export function preprocessor(x: number) { + return { x: x * 10 }; +} +export function main(x: number): number { + return x + 1; +} +"#; + let results = run_deno_raw_protocol_test( + script, + vec![("exec_preprocess", serde_json::json!({"x": 5}))], + ); + assert_eq!(results.len(), 2); + assert_eq!( + results[0], + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50})) + ); + assert_eq!( + results[1], + DedicatedWorkerResult::Success(serde_json::json!(51)) + ); + } + + // Note: no "missing preprocessor" test for Deno because the wrapper only generates + // the exec_preprocess handler when the script actually has a preprocessor function. + // Without one, exec_preprocess messages are unrecognized (by design — Rust never sends them). + + // ==================== Argument Transformation Tests ==================== + + #[test] + fn test_deno_date_arg_transformation() { + let script = r#" +export function main(d: Date): string { + return d instanceof Date ? d.toISOString() : typeof d; +} +"#; + let results = run_deno_worker_test( + script, + vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})], + ); + assert_eq!(results.len(), 1); + assert_eq!( + results[0], + Ok(serde_json::json!("2024-01-15T10:30:00.000Z")) + ); + } } // ============================================================================ diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index 2ffb85a418..f04e189315 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -1,9 +1,470 @@ use serde_json::json; +#[cfg(feature = "python")] use sqlx::postgres::Postgres; +#[cfg(feature = "python")] use sqlx::Pool; +#[cfg(feature = "python")] use windmill_common::scripts::ScriptLang; use windmill_test_utils::*; +// ============================================================================ +// Dedicated Worker Protocol Tests (Python) +// ============================================================================ + +#[cfg(feature = "python")] +mod dedicated_worker_protocol_python { + use std::io::{BufRead, BufReader, Write}; + use std::process::{Command, Stdio}; + use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult}; + use windmill_worker::{compute_py_codegen, generate_py_multi_script_wrapper, PyScriptEntry}; + + struct MultiScriptJob { + script_path: String, + args: serde_json::Value, + } + + /// Creates a multi-script Python wrapper, writes scripts to proper module paths + fn create_py_worker_files( + dir: &std::path::Path, + scripts: &[(&str, &str)], // (original_path, content) + ) -> std::path::PathBuf { + let mut codegens = Vec::new(); + for (path, content) in scripts { + let cg = compute_py_codegen(content, path); + let module_dir = dir.join(&cg.dirs); + std::fs::create_dir_all(&module_dir).unwrap(); + std::fs::write(module_dir.join(format!("{}.py", cg.module_name)), content).unwrap(); + codegens.push((path.to_string(), cg)); + } + + let entries: Vec> = codegens + .iter() + .map(|(path, cg)| PyScriptEntry { original_path: path.as_str(), codegen: cg }) + .collect(); + + let wrapper = generate_py_multi_script_wrapper(&entries, false, false); + let wrapper_path = dir.join("wrapper.py"); + std::fs::write(&wrapper_path, &wrapper).unwrap(); + wrapper_path + } + + fn run_py_multi_script_test( + scripts: &[(&str, &str)], + jobs: Vec, + ) -> Vec> { + let temp_dir = tempfile::tempdir().unwrap(); + create_py_worker_files(temp_dir.path(), scripts); + + let mut child = Command::new("python3") + .args(["-u", "-m", "wrapper"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn python3 process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + let mut start_line = String::new(); + reader.read_line(&mut start_line).unwrap(); + assert_eq!( + parse_dedicated_worker_line(start_line.trim()), + DedicatedWorkerResult::Start, + "Expected 'start', got: {}", + start_line.trim() + ); + + let mut results = Vec::new(); + for job in &jobs { + writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap(); + stdin.flush().unwrap(); + + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + + match parse_dedicated_worker_line(response.trim()) { + DedicatedWorkerResult::Success(value) => results.push(Ok(value)), + DedicatedWorkerResult::Error(err) => { + let msg = err["message"] + .as_str() + .unwrap_or("Unknown error") + .to_string(); + results.push(Err(msg)); + } + other => panic!("Unexpected response: {:?}", other), + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + results + } + + fn run_py_single_script_test( + script_path: &str, + content: &str, + jobs: Vec, + ) -> Vec> { + run_py_multi_script_test( + &[(script_path, content)], + jobs.into_iter() + .map(|args| MultiScriptJob { script_path: script_path.to_string(), args }) + .collect(), + ) + } + + #[test] + fn test_python_dedicated_worker_simple() { + let results = run_py_single_script_test( + "f/test/add", + "def main(a: int, b: int):\n return a + b\n", + vec![serde_json::json!({"a": 3, "b": 4})], + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0], Ok(serde_json::json!(7))); + } + + #[test] + fn test_python_dedicated_worker_multiple_jobs() { + let results = run_py_single_script_test( + "f/test/double", + "def main(n: int):\n return n * 2\n", + (1..=5).map(|i| serde_json::json!({"n": i})).collect(), + ); + assert_eq!(results.len(), 5); + for (i, result) in results.iter().enumerate() { + assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64))); + } + } + + #[test] + fn test_python_multi_script_routing() { + let results = run_py_multi_script_test( + &[ + ( + "f/math/add", + "def main(a: int, b: int):\n return a + b\n", + ), + ( + "f/math/mul", + "def main(x: int, y: int):\n return x * y\n", + ), + ], + vec![ + MultiScriptJob { + script_path: "f/math/add".to_string(), + args: serde_json::json!({"a": 3, "b": 4}), + }, + MultiScriptJob { + script_path: "f/math/mul".to_string(), + args: serde_json::json!({"x": 5, "y": 6}), + }, + MultiScriptJob { + script_path: "f/math/add".to_string(), + args: serde_json::json!({"a": 10, "b": 20}), + }, + ], + ); + assert_eq!(results.len(), 3); + assert_eq!(results[0], Ok(serde_json::json!(7))); + assert_eq!(results[1], Ok(serde_json::json!(30))); + assert_eq!(results[2], Ok(serde_json::json!(30))); + } + + #[test] + fn test_python_multi_script_error_isolation() { + let results = run_py_multi_script_test( + &[ + ("f/ok", "def main(x: int):\n return x * 2\n"), + ("f/err", "def main(msg: str):\n raise Exception(msg)\n"), + ], + vec![ + MultiScriptJob { + script_path: "f/ok".to_string(), + args: serde_json::json!({"x": 5}), + }, + MultiScriptJob { + script_path: "f/err".to_string(), + args: serde_json::json!({"msg": "boom"}), + }, + MultiScriptJob { + script_path: "f/ok".to_string(), + args: serde_json::json!({"x": 10}), + }, + ], + ); + assert_eq!(results.len(), 3); + assert_eq!(results[0], Ok(serde_json::json!(10))); + assert!(results[1].is_err()); + assert_eq!(results[1], Err("boom".to_string())); + assert_eq!(results[2], Ok(serde_json::json!(20))); + } + + #[test] + fn test_python_multi_script_unknown_path() { + let results = run_py_multi_script_test( + &[("f/known", "def main(x: int):\n return x\n")], + vec![MultiScriptJob { + script_path: "f/unknown".to_string(), + args: serde_json::json!({"x": 1}), + }], + ); + assert_eq!(results.len(), 1); + assert!(results[0].is_err()); + assert!(results[0] + .as_ref() + .unwrap_err() + .contains("Script not found")); + } + + // ==================== exec_preprocess Tests ==================== + + /// Raw protocol command for Python + enum ProtocolCmd { + Exec { path: String, args: serde_json::Value }, + ExecPreprocess { path: String, args: serde_json::Value }, + } + + /// Run a Python worker test with raw protocol commands + fn run_py_raw_protocol_test( + scripts: &[(&str, &str)], + commands: Vec, + ) -> Vec { + let temp_dir = tempfile::tempdir().unwrap(); + create_py_worker_files(temp_dir.path(), scripts); + + let mut child = Command::new("python3") + .args(["-u", "-m", "wrapper"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn python3 process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + let mut start_line = String::new(); + reader.read_line(&mut start_line).unwrap(); + assert_eq!( + parse_dedicated_worker_line(start_line.trim()), + DedicatedWorkerResult::Start, + ); + + let mut results = Vec::new(); + + for cmd in &commands { + let line = match cmd { + ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args), + ProtocolCmd::ExecPreprocess { path, args } => { + format!("exec_preprocess:{}:{}", path, args) + } + }; + writeln!(stdin, "{}", line).unwrap(); + stdin.flush().unwrap(); + + let expected_lines = match cmd { + ProtocolCmd::ExecPreprocess { .. } => 2, + ProtocolCmd::Exec { .. } => 1, + }; + + for _ in 0..expected_lines { + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + let parsed = parse_dedicated_worker_line(response.trim()); + if matches!(parsed, DedicatedWorkerResult::Error(_)) { + results.push(parsed); + break; + } + results.push(parsed); + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + + results + } + + #[test] + fn test_python_exec_preprocess() { + let script = r#" +def preprocessor(x: int): + return {"x": x * 10} + +def main(x: int): + return x + 1 +"#; + let results = run_py_raw_protocol_test( + &[("f/test/pre", script)], + vec![ProtocolCmd::ExecPreprocess { + path: "f/test/pre".to_string(), + args: serde_json::json!({"x": 5}), + }], + ); + assert_eq!(results.len(), 2); + assert_eq!( + results[0], + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50})) + ); + // main(50) => 51 + assert_eq!( + results[1], + DedicatedWorkerResult::Success(serde_json::json!(51)) + ); + } + + #[test] + fn test_python_exec_preprocess_missing_preprocessor() { + let script = "def main(x: int):\n return x\n"; + let results = run_py_raw_protocol_test( + &[("f/test/nopre", script)], + vec![ProtocolCmd::ExecPreprocess { + path: "f/test/nopre".to_string(), + args: serde_json::json!({"x": 5}), + }], + ); + assert_eq!(results.len(), 1); + assert!(matches!(results[0], DedicatedWorkerResult::Error(_))); + } + + #[test] + fn test_python_exec_preprocess_then_exec() { + let script = r#" +def preprocessor(x: int): + return {"x": x * 2} + +def main(x: int): + return x + 100 +"#; + let results = run_py_raw_protocol_test( + &[("f/test/mixed", script)], + vec![ + ProtocolCmd::ExecPreprocess { + path: "f/test/mixed".to_string(), + args: serde_json::json!({"x": 5}), + }, + ProtocolCmd::Exec { + path: "f/test/mixed".to_string(), + args: serde_json::json!({"x": 7}), + }, + ], + ); + // preprocess: preprocessor(5) => {"x":10}, main(10) => 110 + // exec: main(7) => 107 + assert_eq!(results.len(), 3); + assert_eq!( + results[0], + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10})) + ); + assert_eq!( + results[1], + DedicatedWorkerResult::Success(serde_json::json!(110)) + ); + assert_eq!( + results[2], + DedicatedWorkerResult::Success(serde_json::json!(107)) + ); + } + + // ==================== Argument Transformation Tests ==================== + + #[test] + fn test_python_datetime_arg_transformation() { + let script = r#" +from datetime import datetime + +def main(d: datetime): + return d.isoformat() +"#; + let results = run_py_single_script_test( + "f/test/dt", + script, + vec![serde_json::json!({"d": "2024-01-15T10:30:00+00:00"})], + ); + assert_eq!(results.len(), 1); + assert_eq!( + results[0], + Ok(serde_json::json!("2024-01-15T10:30:00+00:00")) + ); + } + + #[test] + fn test_python_bytes_arg_transformation() { + let script = r#" +def main(data: bytes): + return len(data) +"#; + // base64 of "hello" is "aGVsbG8=" + let results = run_py_single_script_test( + "f/test/bytes", + script, + vec![serde_json::json!({"data": "aGVsbG8="})], + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0], Ok(serde_json::json!(5))); + } + + #[test] + fn test_python_kwargs_filtering() { + // Test that extra kwargs are filtered out and only declared args are passed + let script = "def main(a: int, b: int):\n return a + b\n"; + let results = run_py_single_script_test( + "f/test/kwargs", + script, + vec![serde_json::json!({"a": 1, "b": 2, "extra": 99})], + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0], Ok(serde_json::json!(3))); + } + + #[test] + fn test_python_function_call_sentinel_removal() { + // Test that '' sentinel values are removed from args + let script = "def main(a: int, b: int = 10):\n return a + b\n"; + let results = run_py_single_script_test( + "f/test/sentinel", + script, + vec![serde_json::json!({"a": 5, "b": ""})], + ); + assert_eq!(results.len(), 1); + // b should be removed (sentinel), default 10 used + assert_eq!(results[0], Ok(serde_json::json!(15))); + } + + // ==================== Relative Import Tests ==================== + + #[test] + fn test_python_dedicated_worker_with_relative_import_detection() { + // Test that the wrapper includes 'import loader' when scripts have relative imports + let script_with_relative = "from f.helper import util\ndef main(x: int):\n return x\n"; + let cg = compute_py_codegen(script_with_relative, "f/test/rel"); + let entries = [PyScriptEntry { original_path: "f/test/rel", codegen: &cg }]; + let wrapper = generate_py_multi_script_wrapper(&entries, false, true); + assert!( + wrapper.contains("import loader"), + "wrapper should contain 'import loader' when any_relative_imports=true" + ); + + // Without relative imports + let script_no_relative = "def main(x: int):\n return x\n"; + let cg2 = compute_py_codegen(script_no_relative, "f/test/norel"); + let entries2 = [PyScriptEntry { original_path: "f/test/norel", codegen: &cg2 }]; + let wrapper2 = generate_py_multi_script_wrapper(&entries2, false, false); + assert!( + !wrapper2.contains("import loader"), + "wrapper should NOT contain 'import loader' when any_relative_imports=false" + ); + } +} + #[cfg(feature = "python")] #[sqlx::test(fixtures("base", "lockfile_python"))] async fn test_requirements_python(db: Pool) -> anyhow::Result<()> { diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 56de4d9892..54ac78fade 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -239,6 +239,7 @@ pub fn workspaced_service() -> Router { "/history_update/h/:hash/p/*path", post(update_script_history), ) + .route("/list_dedicated_with_deps", get(list_dedicated_with_deps)) // Temporary raw script storage for CLI lock generation .route("/raw_temp/store", post(store_raw_script_temp)) .route("/raw_temp/diff", post(diff_raw_scripts_with_deployed)) @@ -2480,6 +2481,62 @@ async fn guard_script_from_debounce_data(ns: &NewScript) -> Result<()> { } } +#[derive(Serialize)] +struct DedicatedScriptDeps { + path: String, + language: ScriptLang, + workspace_dep_names: Vec, +} + +async fn list_dedicated_with_deps( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + + let rows = sqlx::query!( + "SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script + WHERE workspace_id = $1 + AND archived = false + AND dedicated_worker = true + AND language = ANY($2::SCRIPT_LANG[]) + ORDER BY path, created_at DESC", + &w_id, + &[ + ScriptLang::Python3, + ScriptLang::Bun, + ScriptLang::Bunnative, + ScriptLang::Deno, + ] as &[ScriptLang], + ) + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + + let result = rows + .into_iter() + .map(|row| { + let dep_names = + windmill_common::scripts::extract_workspace_dependencies_annotated_refs( + &row.language, + &row.content, + &row.path, + ) + .map(|refs| refs.external) + .unwrap_or_default(); + DedicatedScriptDeps { + path: row.path, + language: row.language, + workspace_dep_names: dep_names, + } + }) + .collect(); + + Ok(Json(result)) +} + // ============================================================================ // Temporary Raw Script Storage for CLI Lock Generation // ============================================================================ @@ -2508,11 +2565,9 @@ async fn store_raw_script_temp( .await?; // Clean up old entries (1 week TTL) - sqlx::query!( - "DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'" - ) - .execute(&db) - .await?; + sqlx::query!("DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'") + .execute(&db) + .await?; Ok(Json(hash)) } @@ -2560,7 +2615,7 @@ async fn diff_raw_scripts_with_deployed( FROM script s \ WHERE s.path = local.path AND s.workspace_id = $3 AND s.archived = false \ ORDER BY s.created_at DESC LIMIT 1 \ - ) deployed ON deployed.deployed_hash = local.hash" + ) deployed ON deployed.deployed_hash = local.hash", ) .bind(&paths) .bind(&hashes) @@ -2582,7 +2637,7 @@ async fn diff_raw_scripts_with_deployed( AND wd.language = $3::SCRIPT_LANG \ AND wd.name IS NOT DISTINCT FROM $4 \ AND encode(sha256(convert_to(wd.content, 'UTF8')), 'hex') = $5 \ - )" + )", ) .bind(&dep.path) .bind(&w_id) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 825842f057..cb86c2a8b9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -6940,6 +6940,60 @@ paths: schema: type: string + /w/{workspace}/scripts/list_dedicated_with_deps: + get: + summary: list dedicated worker scripts with workspace dependency annotations + operationId: listDedicatedWithDeps + tags: + - script + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: list of dedicated scripts with their workspace dependency names + content: + application/json: + schema: + type: array + items: + type: object + properties: + path: + type: string + language: + type: string + enum: + - python3 + - deno + - go + - bash + - powershell + - postgresql + - mysql + - bigquery + - snowflake + - mssql + - graphql + - nativets + - bun + - bunnative + - php + - rust + - ansible + - csharp + - oracledb + - duckdb + - java + - ruby + workspace_dep_names: + type: array + items: + type: string + required: + - path + - language + - workspace_dep_names + /w/{workspace}/scripts/raw/p/{path}: get: summary: raw script by path diff --git a/backend/windmill-common/src/ai_google.rs b/backend/windmill-common/src/ai_google.rs index ccf34685e5..5f8255c10a 100644 --- a/backend/windmill-common/src/ai_google.rs +++ b/backend/windmill-common/src/ai_google.rs @@ -9,7 +9,10 @@ use serde::{Deserialize, Serialize}; -use crate::ai_types::{ContentPart, ExtraContent, GoogleExtraContent, OpenAIContent, OpenAIMessage, ToolDef, UrlCitation}; +use crate::ai_types::{ + ContentPart, ExtraContent, GoogleExtraContent, OpenAIContent, OpenAIMessage, ToolDef, + UrlCitation, +}; use crate::error::Error; // ============================================================================ @@ -87,7 +90,10 @@ pub struct GeminiTextRequest { /// Tool definition — function declarations and/or Google Search grounding. #[derive(Serialize)] pub struct GeminiTool { - #[serde(rename = "functionDeclarations", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "functionDeclarations", + skip_serializing_if = "Option::is_none" + )] pub function_declarations: Option>, #[serde(rename = "googleSearch", skip_serializing_if = "Option::is_none")] pub google_search: Option, @@ -115,7 +121,10 @@ pub struct GeminiToolConfig { #[derive(Serialize)] pub struct GeminiFunctionCallingConfig { pub mode: String, - #[serde(rename = "allowedFunctionNames", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "allowedFunctionNames", + skip_serializing_if = "Option::is_none" + )] pub allowed_function_names: Option>, } @@ -341,10 +350,8 @@ pub fn convert_content_to_gemini_parts(content: &OpenAIContent) -> Vec { - parse_data_url(&image_url.url).map(|(mime_type, data)| { - GeminiPart::InlineData { - inline_data: GeminiInlineData { mime_type, data }, - } + parse_data_url(&image_url.url).map(|(mime_type, data)| GeminiPart::InlineData { + inline_data: GeminiInlineData { mime_type, data }, }) } // S3Objects are handled by the worker @@ -372,15 +379,12 @@ pub fn openai_messages_to_gemini( if let Some(content) = &msg.content { let parts = convert_content_to_gemini_parts(content); if !parts.is_empty() { - system_instruction = - Some(GeminiContentMessage { role: None, parts }); + system_instruction = Some(GeminiContentMessage { role: None, parts }); } } } "tool" => { - if let (Some(tool_call_id), Some(content)) = - (&msg.tool_call_id, &msg.content) - { + if let (Some(tool_call_id), Some(content)) = (&msg.tool_call_id, &msg.content) { let func_name = find_gemini_function_name(messages, tool_call_id); let response_text = match content { OpenAIContent::Text(text) => text.clone(), @@ -435,10 +439,8 @@ pub fn openai_messages_to_gemini( } if !parts.is_empty() { - contents.push(GeminiContentMessage { - role: Some(gemini_role.to_string()), - parts, - }); + contents + .push(GeminiContentMessage { role: Some(gemini_role.to_string()), parts }); } } } @@ -469,10 +471,8 @@ pub fn openai_tools_to_gemini( .collect(); if !declarations.is_empty() { - gemini_tools.push(GeminiTool { - function_declarations: Some(declarations), - google_search: None, - }); + gemini_tools + .push(GeminiTool { function_declarations: Some(declarations), google_search: None }); } if has_websearch { diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 8b20d331c0..b1ba371ea2 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -1531,6 +1531,16 @@ pub fn dedicated_worker_tag(workspace_id: &str, path: &str) -> String { ) } +/// Configuration for a runner group — a single long-lived subprocess +/// that can execute multiple scripts sharing the same workspace dependency. +/// Auto-detected from script content annotations at worker startup. +#[derive(Clone, PartialEq, Debug)] +pub struct RunnerGroupConfig { + pub workspace_id: String, + pub dep_name: String, + pub language: String, +} + pub async fn load_worker_config( db: &DB, killpill_tx: KillpillSender, @@ -1638,7 +1648,7 @@ pub async fn load_worker_config( let worker_tags = config .worker_tags .or_else(|| { - // Check for multiple dedicated workers first + // Check for multiple dedicated workers if let Some(ref dws) = dedicated_workers.as_ref() { let mut dedi_tags: Vec = dws .iter() diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 8e83eae1da..ef2bf66513 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -899,6 +899,8 @@ pub enum DedicatedWorkerResult { Start, /// Worker returned a successful result Success(serde_json::Value), + /// Worker returned preprocessed args (from exec_preprocess) + PreprocessedArgs(serde_json::Value), /// Worker returned an error result Error(serde_json::Value), /// Line is not a protocol message (e.g., logs) @@ -908,6 +910,7 @@ pub enum DedicatedWorkerResult { /// Parse a line from dedicated worker stdout according to the protocol: /// - "start" -> Ready signal /// - "wm_res[success]:JSON" -> Success with result +/// - "wm_res[preprocessed_args]:JSON" -> Preprocessed args /// - "wm_res[error]:JSON" -> Error with details /// - anything else -> Other (logs) pub fn parse_dedicated_worker_line(line: &str) -> DedicatedWorkerResult { @@ -922,6 +925,13 @@ pub fn parse_dedicated_worker_line(line: &str) -> DedicatedWorkerResult { } } + if let Some(json_str) = line.strip_prefix("wm_res[preprocessed_args]:") { + match serde_json::from_str(json_str) { + Ok(value) => return DedicatedWorkerResult::PreprocessedArgs(value), + Err(_) => return DedicatedWorkerResult::Other(line.to_string()), + } + } + if let Some(json_str) = line.strip_prefix("wm_res[error]:") { match serde_json::from_str(json_str) { Ok(value) => return DedicatedWorkerResult::Error(value), diff --git a/backend/windmill-worker/loader.bun.js b/backend/windmill-worker/loader.bun.js index f2a00de0cf..d03f19b6ba 100644 --- a/backend/windmill-worker/loader.bun.js +++ b/backend/windmill-worker/loader.bun.js @@ -21,7 +21,7 @@ const p = { // On Windows, normalize path to POSIX format to match args.path from Bun's resolver const cdirPosix = cdir.replace(/\\/g, "/").replace(/^[a-zA-Z]:/, ""); const filterResolve = new RegExp( - `^(?!\\.\/main\\.ts)(?!${cdir}\/main\\.ts)(?!${cdirPosix}\/main\\.ts)(?!(?:/private)?${cdirNoPrivate}\/wrapper\\.mjs).*\\.ts$` + `^(?!\\.\/main\\.ts)(?!\\.\/_wm_)(?!${cdir}\/main\\.ts)(?!${cdir}\/_wm_)(?!${cdirPosix}\/main\\.ts)(?!${cdirPosix}\/_wm_)(?!(?:/private)?${cdirNoPrivate}\/wrapper\\.mjs).*\\.ts$` ); let cdirNodeModules = `${cdir}/node_modules/`; diff --git a/backend/windmill-worker/loader.bun.windows.js b/backend/windmill-worker/loader.bun.windows.js index 7d14ffcbc3..e89b70c26c 100644 --- a/backend/windmill-worker/loader.bun.windows.js +++ b/backend/windmill-worker/loader.bun.windows.js @@ -27,7 +27,7 @@ const p = { const cdirFwd = cdir.replace(/\\/g, "/"); const cdirPosix = cdirFwd.replace(/^[a-zA-Z]:/, ""); const filterResolve = new RegExp( - `^(?!\\.\/main\\.ts)(?!${cdirFwd}\/main\\.ts)(?!${cdirPosix}\/main\\.ts)(?!(?:/private)?${cdirNoPrivate}\/wrapper\\.mjs).*\\.ts$` + `^(?!\\.\/main\\.ts)(?!\\.\/_wm_)(?!${cdirFwd}\/main\\.ts)(?!${cdirFwd}\/_wm_)(?!${cdirPosix}\/main\\.ts)(?!${cdirPosix}\/_wm_)(?!(?:/private)?${cdirNoPrivate}\/wrapper\\.mjs).*\\.ts$` ); let cdirNodeModules = `${cdirFwd}/node_modules/`; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 7dbaa6723d..6bc6cacc32 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -77,95 +77,236 @@ pub const EMPTY_FILE: &str = ""; /// Bun args for dedicated worker (without the script path) pub const BUN_DEDICATED_WORKER_ARGS: &[&str] = &["run", "-i", "--prefer-offline"]; -/// Generate the dedicated worker wrapper content. -/// - `arg_names`: The argument names for the main function (e.g., ["x", "y"]) -/// - `main_import`: The import path for the main module (e.g., "./main.ts") -/// - `date_conversions`: Optional date conversion statements for Datetime args -/// - `preprocessor_spread`: If the script has a preprocessor function, the comma-separated arg names for it -pub fn generate_dedicated_worker_wrapper( - arg_names: &[&str], - main_import: &str, - date_conversions: Option<&str>, - preprocessor_spread: Option<&str>, -) -> String { - let spread = arg_names.join(","); - let dates = date_conversions.unwrap_or(""); +/// Pre-computed codegen data for a TypeScript/Bun/Deno script. +/// Computed in Rust from the parsed signature, then baked into the wrapper template. +#[cfg(any(feature = "private", test))] +pub struct TsScriptCodegen { + pub spread: String, + pub date_conversions: String, + pub preprocessor_spread: Option, + pub preprocessor_date_conversions: Option, +} + +/// Parse a TS script and compute the codegen data (arg spread, date conversions, preprocessor). +/// This is the same logic that was used on main in `start_worker`. +#[cfg(any(feature = "private", test))] +pub fn compute_ts_codegen(content: &str) -> TsScriptCodegen { + let sig = + windmill_parser_ts::parse_deno_signature(content, true, false, None).unwrap_or_default(); + let arg_names: Vec<&str> = sig.args.iter().map(|a| a.name.as_str()).collect(); + let spread = arg_names.join(", "); + + let dates = sig + .args + .iter() + .filter(|a| matches!(a.typ, Typ::Datetime)) + .map(|a| { + format!( + "{name} = {name} ? new Date({name}) : undefined", + name = a.name + ) + }) + .join("\n "); + + let pre_sig = windmill_parser_ts::parse_deno_signature( + content, + true, + false, + Some("preprocessor".to_string()), + ) + .ok() + .filter(|s| !s.args.is_empty()); + + let preprocessor_spread = pre_sig + .as_ref() + .map(|s| s.args.iter().map(|a| a.name.as_str()).join(", ")); + let preprocessor_date_conversions = pre_sig.as_ref().map(|s| { + s.args + .iter() + .filter(|a| matches!(a.typ, Typ::Datetime)) + .map(|a| { + format!( + "{name} = {name} ? new Date({name}) : undefined", + name = a.name + ) + }) + .join("\n ") + }); + + TsScriptCodegen { + spread, + date_conversions: dates, + preprocessor_spread, + preprocessor_date_conversions, + } +} + +/// Script entry for the unified wrapper generator. +/// `import_name`: the file stem used in the import path (e.g., "main" → `./main.ts`, or "f__script" → `./f__script.ts`) +#[cfg(any(feature = "private", test))] +pub struct TsScriptEntry<'a> { + pub import_name: &'a str, + pub original_path: &'a str, + pub codegen: &'a TsScriptCodegen, +} + +/// Generate a wrapper for dedicated workers and runner groups. +/// All scripts are baked in at codegen time with static imports and inline arg handling. +/// Protocol: +/// exec:: -> wm_res[success]: | wm_res[error]: +/// exec_preprocess:: -> wm_res[preprocessed_args]: then wm_res[success]: | wm_res[error]: +/// end -> exit +#[cfg(any(feature = "private", test))] +pub fn generate_multi_script_wrapper(scripts: &[TsScriptEntry<'_>], ext: &str) -> String { let is_debug = std::env::var("RUST_LOG").is_ok_and(|x| x == "windmill=debug"); let print_lines = if is_debug { - r#"console.log(line);"# + r#"console.log("[debug] " + line);"# } else { "" }; - let preprocessor_logic = if let Some(pre_spread) = preprocessor_spread { - format!( + let imports: String = scripts + .iter() + .enumerate() + .map(|(i, e)| { + format!( + "import * as _s{i} from \"./{import_name}.{ext}\";", + import_name = e.import_name + ) + }) + .collect::>() + .join("\n"); + + // Generate per-script getArgs / getPreArgs functions + let mut functions = String::new(); + let mut registrations = String::new(); + + for (i, entry) in scripts.iter().enumerate() { + let cg = entry.codegen; + let spread = &cg.spread; + let dates = &cg.date_conversions; + + functions.push_str(&format!( r#" - if (rawLine.startsWith("preprocess:")) {{ - const preInput = rawLine.slice("preprocess:".length); - const parsedArgs = JSON.parse(preInput); - if (Main.preprocessor === undefined || typeof Main.preprocessor !== 'function') {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: "preprocessor function is missing", name: "Error" }})); - continue; - }} - try {{ - function preArgsObjToArr({{ {pre_spread} }}) {{ - return [ {pre_spread} ]; - }} - const preprocessedArgs = await Main.preprocessor(...preArgsObjToArr(parsedArgs)); - console.log("wm_res[preprocessed_args]:" + JSON.stringify(preprocessedArgs ?? {{}}, (key, value) => typeof value === 'undefined' ? null : value)); - // Now call main with preprocessed args - const mainArgs = getArgs(JSON.stringify(preprocessedArgs ?? {{}})); - const res = await Main.main(...mainArgs); - console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value)); - }} catch (e) {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: rawLine }})); - }} - continue; - }}"# - ) - } else { - String::new() - }; +function getArgs_{i}(line) {{ + let {{ {spread} }} = JSON.parse(line); + {dates} + return [ {spread} ]; +}} +"# + )); + + let pre_fn = if let Some(ref pre_spread) = cg.preprocessor_spread { + let pre_dates = cg.preprocessor_date_conversions.as_deref().unwrap_or(""); + functions.push_str(&format!( + r#" +function getPreArgs_{i}(line) {{ + let {{ {pre_spread} }} = JSON.parse(line); + {pre_dates} + return [ {pre_spread} ]; +}} +"# + )); + format!("getPreArgs_{i}") + } else { + "null".to_string() + }; + + registrations.push_str(&format!( + "scripts.set(\"{path}\", {{ module: _s{i}, getArgs: getArgs_{i}, getPreArgs: {pre_fn} }});\n", + path = entry.original_path, + )); + } format!( r#" -import * as Main from "{main_import}"; +{imports} import * as Readline from "node:readline" BigInt.prototype.toJSON = function () {{ return this.toString(); }}; -console.log('start'); +const scripts = new Map(); +{functions} +{registrations} -function getArgs(line) {{ - let {{ {spread} }} = JSON.parse(line) - {dates} - return [ {spread} ]; -}} +console.log('start'); for await (const line of Readline.createInterface({{ input: process.stdin }})) {{ {print_lines} - const rawLine = line; - if (rawLine === "end") {{ + if (line === "end") {{ process.exit(0); }} - {preprocessor_logic} - try {{ - const args = getArgs(rawLine); - const res = await Main.main(...args); - console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value)); - }} catch (e) {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: rawLine }})); + + if (line.startsWith("exec_preprocess:")) {{ + const rest = line.slice("exec_preprocess:".length); + const colonIdx = rest.indexOf(":"); + if (colonIdx === -1) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Malformed exec_preprocess command: missing colon separator", name: "Error" }})); + continue; + }} + const scriptPath = rest.slice(0, colonIdx); + const argsJson = rest.slice(colonIdx + 1); + + const entry = scripts.get(scriptPath); + if (!entry) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Script not found: " + scriptPath, name: "Error" }})); + continue; + }} + + try {{ + if (!entry.getPreArgs) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "preprocessor function is missing", name: "Error" }})); + continue; + }} + const preArgs = entry.getPreArgs(argsJson); + const preprocessedArgs = await entry.module.preprocessor(...preArgs); + console.log("wm_res[preprocessed_args]:" + JSON.stringify(preprocessedArgs ?? {{}}, (key, value) => typeof value === 'undefined' ? null : value)); + const mainArgs = entry.getArgs(JSON.stringify(preprocessedArgs ?? {{}})); + const res = await entry.module.main(...mainArgs); + console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value)); + }} catch (e) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: argsJson }})); + }} + continue; }} + + if (line.startsWith("exec:")) {{ + const rest = line.slice("exec:".length); + const colonIdx = rest.indexOf(":"); + if (colonIdx === -1) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Malformed exec command: missing colon separator", name: "Error" }})); + continue; + }} + const scriptPath = rest.slice(0, colonIdx); + const argsJson = rest.slice(colonIdx + 1); + + const entry = scripts.get(scriptPath); + if (!entry) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Script not found: " + scriptPath, name: "Error" }})); + continue; + }} + + try {{ + const args = entry.getArgs(argsJson); + const res = await entry.module.main(...args); + console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value)); + }} catch (e) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: argsJson }})); + }} + continue; + }} + + console.error("Unknown command:", line); }} "# ) } /// Returns (package.json, bun.lock(b), is_empty, is_binary) -fn split_lockfile(lockfile: &str) -> (&str, Option<&str>, bool, bool) { +pub(crate) fn split_lockfile(lockfile: &str) -> (&str, Option<&str>, bool, bool) { if let Some(index) = lockfile.find(BUN_LOCK_SPLIT) { // Split using "\n//bun.lock\n" let (before, after_with_sep) = lockfile.split_at(index); @@ -3587,53 +3728,18 @@ pub async fn start_worker( let main_code = remove_pinned_imports(inner_content)?; let _ = write_file(job_dir, "main.ts", &main_code)?; + let codegen = compute_ts_codegen(inner_content); + let wrapper_ext = if codebase.is_some() { "js" } else { "ts" }; { - // let mut start = Instant::now(); - let args = windmill_parser_ts::parse_deno_signature(inner_content, true, false, None)?.args; - let dates = args - .iter() - .filter_map(|x| { - if matches!(x.typ, Typ::Datetime) { - Some(x.name.clone()) - } else { - None - } - }) - .map(|x| return format!("{x} = {x} ? new Date({x}) : undefined")) - .join("\n"); - - let arg_names: Vec<&str> = args.iter().map(|x| x.name.as_str()).collect(); - - // Parse preprocessor signature if it exists - let pre_spread = windmill_parser_ts::parse_deno_signature( - inner_content, - true, - false, - Some("preprocessor".to_string()), - ) - .ok() - .filter(|sig| !sig.args.is_empty()) - .map(|sig| sig.args.into_iter().map(|x| x.name).join(",")); - - // logs.push_str(format!("infer args: {:?}\n", start.elapsed().as_micros()).as_str()); - // we cannot use Bun.read and Bun.write because it results in an EBADF error on cloud - - let main_import = if codebase.is_some() { - "./main.js" - } else { - "./main.ts" - }; - let dates_opt = if dates.is_empty() { - None - } else { - Some(dates.as_str()) - }; - let wrapper_content = generate_dedicated_worker_wrapper( - &arg_names, - main_import, - dates_opt, - pre_spread.as_deref(), - ); + let scripts = + [ + TsScriptEntry { + import_name: "main", + original_path: script_path, + codegen: &codegen, + }, + ]; + let wrapper_content = generate_multi_script_wrapper(&scripts, wrapper_ext); write_file(job_dir, "wrapper.mjs", &wrapper_content)?; } @@ -3832,4 +3938,48 @@ lockfile-content"#; assert!(!is_empty); assert!(!is_binary); } + + #[test] + fn test_compute_ts_codegen_basic_args() { + let code = r#"export function main(x: string, y: number) { return x; }"#; + let cg = compute_ts_codegen(code); + assert_eq!(cg.spread, "x, y"); + assert!(cg.date_conversions.is_empty()); + assert!(cg.preprocessor_spread.is_none()); + } + + #[test] + fn test_compute_ts_codegen_with_datetime() { + let code = r#"export function main(name: string, created_at: Date, count: number) { return name; }"#; + let cg = compute_ts_codegen(code); + assert_eq!(cg.spread, "name, created_at, count"); + assert!(cg.date_conversions.contains("created_at")); + assert!(cg.date_conversions.contains("new Date")); + } + + #[test] + fn test_compute_ts_codegen_with_preprocessor() { + let code = r#" +export function main(x: string, ts: Date) { return x; } +export function preprocessor(input: string, when: Date) { return { x: input, ts: when }; } +"#; + let cg = compute_ts_codegen(code); + assert_eq!(cg.spread, "x, ts"); + assert!(cg.date_conversions.contains("ts")); + assert_eq!(cg.preprocessor_spread.as_deref(), Some("input, when")); + assert!(cg + .preprocessor_date_conversions + .as_ref() + .unwrap() + .contains("when")); + } + + #[test] + fn test_compute_ts_codegen_no_args() { + let code = r#"export function main() { return 42; }"#; + let cg = compute_ts_codegen(code); + assert!(cg.spread.is_empty()); + assert!(cg.date_conversions.is_empty()); + assert!(cg.preprocessor_spread.is_none()); + } } diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index 46f42af3bf..a0fd9fdde8 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -27,6 +27,16 @@ use windmill_common::{ }; use windmill_parser::Typ; +pub const DENO_UNSTABLE_ARGS: &[&str] = &[ + "--unstable-unsafe-proto", + "--unstable-bare-node-builtins", + "--unstable-webgpu", + "--unstable-ffi", + "--unstable-fs", + "--unstable-worker-options", + "--unstable-http", +]; + lazy_static::lazy_static! { static ref DENO_FLAGS: Option> = std::env::var("DENO_FLAGS") @@ -172,22 +182,14 @@ pub async fn generate_deno_lock( let mut child_cmd = Command::new(DENO_PATH.as_str()); child_cmd .current_dir(job_dir) - .args(vec![ - "cache", - "--unstable-unsafe-proto", - "--unstable-bare-node-builtins", - "--unstable-webgpu", - "--unstable-ffi", - "--unstable-fs", - "--unstable-worker-options", - "--unstable-http", + .args(["cache"].iter().chain(DENO_UNSTABLE_ARGS).chain(&[ "--lock=lock.json", "--frozen=false", "--allow-import", "--import-map", - &import_map_path, + import_map_path.as_str(), "main.ts", - ]) + ])) .envs(deno_envs) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -442,13 +444,7 @@ try {{ args.push("--import-map"); args.push(&import_map_path); args.push(&reload); - args.push("--unstable-unsafe-proto"); - args.push("--unstable-bare-node-builtins"); - args.push("--unstable-webgpu"); - args.push("--unstable-ffi"); - args.push("--unstable-fs"); - args.push("--unstable-worker-options"); - args.push("--unstable-http"); + args.extend_from_slice(DENO_UNSTABLE_ARGS); if !*DISABLE_DENO_LOCK { if let Some(reqs) = requirements_o { @@ -547,7 +543,7 @@ try {{ read_result(job_dir, handle_result.result_stream).await } -async fn build_import_map( +pub(crate) async fn build_import_map( w_id: &str, script_path: &str, base_internal_url: &str, @@ -592,6 +588,127 @@ async fn build_import_map( #[cfg(feature = "private")] use crate::{dedicated_worker_oss::handle_dedicated_process, JobCompletedSender}; +/// Generate the dedicated worker wrapper for Deno. +/// Parses the script signature and bakes in arg destructuring, date conversions, +/// and preprocessor logic. Uses the `exec::` protocol. +#[cfg(any(feature = "private", test))] +pub fn generate_dedicated_worker_wrapper(inner_content: &str) -> Result { + let args = windmill_parser_ts::parse_deno_signature(inner_content, true, false, None)?.args; + let dates = args + .iter() + .filter_map(|x| { + if matches!(x.typ, Typ::Datetime) { + Some(x.name.clone()) + } else { + None + } + }) + .map(|x| format!("{x} = {x} ? new Date({x}) : undefined")) + .join("\n"); + + let spread = args.into_iter().map(|x| x.name).join(","); + + let pre_spread = windmill_parser_ts::parse_deno_signature( + inner_content, + true, + false, + Some("preprocessor".to_string()), + ) + .ok() + .filter(|sig| !sig.args.is_empty()) + .map(|sig| sig.args.into_iter().map(|x| x.name).join(",")); + + let preprocessor_import = if pre_spread.is_some() { + r#"import { preprocessor } from "./main.ts";"# + } else { + "" + }; + + let preprocessor_logic = if let Some(ref pre_spread) = pre_spread { + format!( + r#" + if (line.startsWith("exec_preprocess:")) {{ + const rest = line.slice("exec_preprocess:".length); + const colonIdx = rest.indexOf(":"); + if (colonIdx === -1) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Malformed exec_preprocess command: missing colon separator", name: "Error" }}) + '\n'); + continue; + }} + const argsJson = rest.slice(colonIdx + 1); + const parsedArgs = JSON.parse(argsJson); + if (typeof preprocessor !== 'function') {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "preprocessor function is missing", name: "Error" }}) + '\n'); + continue; + }} + try {{ + function preArgsObjToArr({{ {pre_spread} }}: any) {{ + return [ {pre_spread} ]; + }} + const preprocessedArgs: any = await preprocessor(...preArgsObjToArr(parsedArgs)); + console.log("wm_res[preprocessed_args]:" + JSON.stringify(preprocessedArgs ?? {{}}, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); + let {{ {spread} }} = preprocessedArgs ?? {{}}; + {dates} + let res: any = await main(...[ {spread} ]); + console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); + }} catch (e) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: line }}) + '\n'); + }} + continue; + }}"# + ) + } else { + String::new() + }; + + Ok(format!( + r#" +import {{ main }} from "./main.ts"; +{preprocessor_import} + +BigInt.prototype.toJSON = function () {{ + return this.toString(); +}}; + +console.log('start\n'); + +const decoder = new TextDecoder(); +for await (const chunk of Deno.stdin.readable) {{ + const lines = decoder.decode(chunk); + let exit = false; + for (const line of lines.trim().split("\n")) {{ + if (line === "end") {{ + exit = true; + break; + }} + {preprocessor_logic} + if (line.startsWith("exec:")) {{ + const rest = line.slice("exec:".length); + const colonIdx = rest.indexOf(":"); + if (colonIdx === -1) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Malformed exec command: missing colon separator", name: "Error" }}) + '\n'); + continue; + }} + const argsJson = rest.slice(colonIdx + 1); + try {{ + let {{ {spread} }} = JSON.parse(argsJson) + {dates} + let res: any = await main(...[ {spread} ]); + console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); + }} catch (e) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: argsJson }}) + '\n'); + }} + continue; + }} + console.error("Unknown command:", line); + }} + if (exit) {{ + break; + }} +}} +"#, + )) +} + #[cfg(feature = "private")] use tokio::sync::mpsc::Receiver; #[cfg(feature = "private")] @@ -650,115 +767,19 @@ pub async fn start_worker( let context_envs = build_envs_map(context.to_vec()).await; { - // let mut start = Instant::now(); - let args = windmill_parser_ts::parse_deno_signature(inner_content, true, false, None)?.args; - let dates = args - .iter() - .filter_map(|x| { - if matches!(x.typ, Typ::Datetime) { - Some(x.name.clone()) - } else { - None - } - }) - .map(|x| return format!("{x} = {x} ? new Date({x}) : undefined")) - .join("\n"); - - let spread = args.into_iter().map(|x| x.name).join(","); - - // Parse preprocessor signature if it exists - let pre_spread = windmill_parser_ts::parse_deno_signature( - inner_content, - true, - false, - Some("preprocessor".to_string()), - ) - .ok() - .filter(|sig| !sig.args.is_empty()) - .map(|sig| sig.args.into_iter().map(|x| x.name).join(",")); - - let preprocessor_import = if pre_spread.is_some() { - r#"import { preprocessor } from "./main.ts";"# - } else { - "" - }; - - let preprocessor_logic = if let Some(ref pre_spread) = pre_spread { - format!( - r#" - if (line.startsWith("preprocess:")) {{ - const preInput = line.slice("preprocess:".length); - const parsedArgs = JSON.parse(preInput); - if (typeof preprocessor !== 'function') {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: "preprocessor function is missing", name: "Error" }}) + '\n'); - continue; - }} - try {{ - function preArgsObjToArr({{ {pre_spread} }}: any) {{ - return [ {pre_spread} ]; - }} - const preprocessedArgs: any = await preprocessor(...preArgsObjToArr(parsedArgs)); - console.log("wm_res[preprocessed_args]:" + JSON.stringify(preprocessedArgs ?? {{}}, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); - // Now call main with preprocessed args - let {{ {spread} }} = preprocessedArgs ?? {{}}; - {dates} - let res: any = await main(...[ {spread} ]); - console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); - }} catch (e) {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: line }}) + '\n'); - }} - continue; - }}"# - ) - } else { - String::new() - }; - - // logs.push_str(format!("infer args: {:?}\n", start.elapsed().as_micros()).as_str()); - // we cannot use Bun.read and Bun.write because it results in an EBADF error on cloud - let wrapper_content: String = format!( - r#" -import {{ main }} from "./main.ts"; -{preprocessor_import} - -BigInt.prototype.toJSON = function () {{ - return this.toString(); -}}; - -{dates} - -console.log('start\n'); - -const decoder = new TextDecoder(); -for await (const chunk of Deno.stdin.readable) {{ - const lines = decoder.decode(chunk); - let exit = false; - for (const line of lines.trim().split("\n")) {{ - if (line === "end") {{ - exit = true; - break; - }} - {preprocessor_logic} - try {{ - let {{ {spread} }} = JSON.parse(line) - {dates} - let res: any = await main(...[ {spread} ]); - console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); - }} catch (e) {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: line }}) + '\n'); - }} - }} - if (exit) {{ - break; - }} -}} -"#, - ); + let wrapper_content = generate_dedicated_worker_wrapper(inner_content)?; write_file(job_dir, "wrapper.ts", &wrapper_content)?; } build_import_map(w_id, script_path, base_internal_url, job_dir).await?; + let import_map = format!("{job_dir}/import_map.json"); + let reload = format!("--reload={base_internal_url}"); + let wrapper = format!("{job_dir}/wrapper.ts"); + let mut deno_args = vec!["run", "--no-check", "--import-map", &import_map, &reload]; + deno_args.extend_from_slice(DENO_UNSTABLE_ARGS); + deno_args.extend_from_slice(&["-A", &wrapper]); + handle_dedicated_process( &*DENO_PATH, job_dir, @@ -766,22 +787,7 @@ for await (const chunk of Deno.stdin.readable) {{ envs, context, common_deno_proc_envs, - vec![ - "run", - "--no-check", - "--import-map", - &format!("{job_dir}/import_map.json"), - &format!("--reload={base_internal_url}"), - "--unstable-unsafe-proto", - "--unstable-bare-node-builtins", - "--unstable-webgpu", - "--unstable-ffi", - "--unstable-fs", - "--unstable-worker-options", - "--unstable-http", - "-A", - &format!("{job_dir}/wrapper.ts"), - ], + deno_args, killpill_rx, job_completed_tx, token, diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index 2fe56f50b9..e97ec8ff9a 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -86,12 +86,23 @@ pub use otel_tracing_proxy_ee::{load_internal_otel_exporter, DENO_OTEL_INITIALIZ pub use worker::*; pub use bun_executor::{ - build_loader, compute_bundle_local_and_remote_path, generate_dedicated_worker_wrapper, - get_common_bun_proc_envs, install_bun_lockfile, prebundle_bun_script, prepare_job_dir, - LoaderMode, BUN_DEDICATED_WORKER_ARGS, RELATIVE_BUN_BUILDER, RELATIVE_BUN_LOADER, + build_loader, compute_bundle_local_and_remote_path, get_common_bun_proc_envs, + install_bun_lockfile, prebundle_bun_script, prepare_job_dir, LoaderMode, + BUN_DEDICATED_WORKER_ARGS, RELATIVE_BUN_BUILDER, RELATIVE_BUN_LOADER, }; -pub use deno_executor::generate_deno_lock; +#[cfg(any(feature = "private", test))] +pub use bun_executor::{ + compute_ts_codegen, generate_multi_script_wrapper, TsScriptCodegen, TsScriptEntry, +}; +#[cfg(any(feature = "private", test))] +pub use deno_executor::generate_dedicated_worker_wrapper as generate_deno_dedicated_worker_wrapper; +pub use deno_executor::{generate_deno_lock, DENO_UNSTABLE_ARGS}; pub use prepare_deps::run_prepare_deps_cli; +#[cfg(all(feature = "python", any(feature = "private", test)))] +pub use python_executor::{ + compute_py_codegen, generate_multi_script_wrapper as generate_py_multi_script_wrapper, + PyScriptCodegen, PyScriptEntry, +}; #[cfg(feature = "python")] pub use python_versions::PyV; diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 3b6507a7e0..78d3d54e15 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -117,7 +117,12 @@ async fn handle_piptar_uploads(mut rx: tokio::sync::mpsc::UnboundedReceiver bool { + RELATIVE_IMPORT_REGEX.is_match(content) +} #[cfg(all(feature = "enterprise", feature = "parquet", unix))] use crate::global_cache::pull_from_tar; @@ -1084,6 +1089,320 @@ fn python_preprocessor_spread(sig: windmill_parser::MainArgSignature, indent: &s } } +/// Pre-computed codegen data for a Python script. +/// Computed in Rust from the parsed signature, then baked into the wrapper template. +#[cfg(any(feature = "private", test))] +pub struct PyScriptCodegen { + /// Python module directory dot notation (e.g., "f.my") + pub module_dir_dot: String, + /// Python module name / last path component (e.g., "script") + pub module_name: String, + /// Directory path for the module (e.g., "f/my") + pub dirs: String, + /// Inline Python code for type transforms (dates, bytes, etc.) + pub transforms: String, + /// Inline Python code for arg spread / filtering + pub spread: String, + /// Inline Python code for preprocessor arg spread (if applicable) + pub pre_spread: Option, +} + +/// Parse a Python script and compute the codegen data. +/// This reuses the same logic that was used on main in `prepare_wrapper`. +#[cfg(any(feature = "private", test))] +pub fn compute_py_codegen(content: &str, script_path: &str) -> PyScriptCodegen { + let dirs = compute_python_module_dir(script_path); + let last = script_path + .split("/") + .map(|x| { + if x.starts_with(|x: char| x.is_ascii_digit()) { + format!("_{}", x) + } else { + x.to_string() + } + }) + .last() + .unwrap() + .replace("-", "_") + .replace(" ", "_") + .to_lowercase(); + + let sig = windmill_parser_py::parse_python_signature(content, None, false).unwrap_or_default(); + let pre_sig = windmill_parser_py::parse_python_signature( + content, + Some("preprocessor".to_string()), + false, + ) + .ok() + .filter(|s| !s.args.is_empty()); + + let init_sig = pre_sig.as_ref().unwrap_or(&sig); + + let transforms = init_sig + .args + .iter() + .map(|x| match x.typ { + windmill_parser::Typ::Bytes => { + let name = &x.name; + format!( + "if \"{name}\" in kwargs and kwargs[\"{name}\"] is not None:\n \ + kwargs[\"{name}\"] = base64.b64decode(kwargs[\"{name}\"])\n", + ) + } + windmill_parser::Typ::Datetime => { + let name = &x.name; + format!( + "if \"{name}\" in kwargs and kwargs[\"{name}\"] is not None:\n \ + kwargs[\"{name}\"] = datetime.fromisoformat(kwargs[\"{name}\"])\n", + ) + } + windmill_parser::Typ::Date => { + let name = &x.name; + format!( + "if \"{name}\" in kwargs and kwargs[\"{name}\"] is not None:\n \ + try:\n \ + kwargs[\"{name}\"] = date.fromisoformat(kwargs[\"{name}\"])\n \ + except ValueError:\n \ + for _fmt in (\"%d-%m-%Y\", \"%m/%d/%Y\", \"%d/%m/%Y\", \"%Y/%m/%d\"):\n \ + try:\n \ + kwargs[\"{name}\"] = datetime.strptime(kwargs[\"{name}\"], _fmt).date()\n \ + break\n \ + except ValueError:\n \ + continue\n", + ) + } + _ => "".to_string(), + }) + .collect::>() + .join(""); + + let spread = if sig.star_kwargs { + "args = kwargs".to_string() + } else { + sig.args + .into_iter() + .map(|x| { + let name = &x.name; + if x.default.is_none() { + format!("args[\"{name}\"] = kwargs.get(\"{name}\")") + } else { + format!( + r#"args["{name}"] = kwargs.get("{name}") + if args["{name}"] is None: + del args["{name}"]"# + ) + } + }) + .join("\n ") + }; + + let pre_spread = pre_sig.map(|sig| python_preprocessor_spread(sig, " ")); + + let module_dir_dot = dirs.replace("/", ".").replace("-", "_"); + + PyScriptCodegen { module_dir_dot, module_name: last, dirs, transforms, spread, pre_spread } +} + +/// Script entry for the Python unified wrapper generator. +#[cfg(any(feature = "private", test))] +pub struct PyScriptEntry<'a> { + pub original_path: &'a str, + pub codegen: &'a PyScriptCodegen, +} + +/// Generate a wrapper for Python dedicated workers and runner groups. +/// All scripts are baked in at codegen time with proper Python imports and inline arg handling. +/// Protocol: +/// exec:: -> wm_res[success]: | wm_res[error]: +/// exec_preprocess:: -> wm_res[preprocessed_args]: then wm_res[success]: | wm_res[error]: +/// end -> exit +#[cfg(any(feature = "private", test))] +pub fn generate_multi_script_wrapper( + scripts: &[PyScriptEntry<'_>], + skip_result_postprocessing: bool, + any_relative_imports: bool, +) -> String { + let postprocessor = get_result_postprocessor(skip_result_postprocessing); + let res_to_json_body = python_res_to_json_body(postprocessor); + + let imports: String = scripts + .iter() + .enumerate() + .map(|(i, e)| { + format!( + "from {module_dir_dot} import {module_name} as _s{i}", + module_dir_dot = e.codegen.module_dir_dot, + module_name = e.codegen.module_name, + ) + }) + .collect::>() + .join("\n"); + + let mut functions = String::new(); + let mut registrations = String::new(); + + for (i, entry) in scripts.iter().enumerate() { + let cg = entry.codegen; + let indented_transforms = cg + .transforms + .split('\n') + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n"); + + functions.push_str(&format!( + r#" +def transform_{i}(kwargs): +{indented_transforms} + args = dict() + {spread} + for k, v in list(args.items()): + if v == '': + del args[k] + return args +"#, + spread = cg.spread + )); + + let pre_fn = if let Some(ref pre_spread) = cg.pre_spread { + functions.push_str(&format!( + r#" +def pre_transform_{i}(kwargs): + pre_args = dict() + {pre_spread} + for k, v in list(pre_args.items()): + if v == '': + del pre_args[k] + return pre_args +"#, + )); + format!("pre_transform_{i}") + } else { + "None".to_string() + }; + + registrations.push_str(&format!( + "scripts[\"{path}\"] = {{ 'mod': _s{i}, 'transform': transform_{i}, 'pre_transform': {pre_fn} }}\n", + path = entry.original_path, + )); + } + + let import_loader = if any_relative_imports { + "import loader" + } else { + "" + }; + + format!( + r#" +import json +import sys +import traceback +import re +import base64 +from datetime import datetime, date +{import_loader} + +{imports} + +scripts = {{}} + +def to_b_64(v: bytes): + b64 = base64.b64encode(v) + return b64.decode('ascii') + +replace_invalid_fields = re.compile(r'(?:\bNaN\b|\\u0000|Infinity|\-Infinity)') + +def res_to_json(res, typ): +{res_to_json_body} +{functions} +{registrations} + +sys.stdout.write('start\n') +sys.stdout.flush() + +for line in sys.stdin: + line = line.strip() + if line == 'end': + break + + if line.startswith('exec_preprocess:'): + try: + rest = line[len('exec_preprocess:'):] + colon_idx = rest.index(':') + script_path = rest[:colon_idx] + args_json = rest[colon_idx + 1:] + + entry = scripts.get(script_path) + if not entry: + err_json = json.dumps({{ "message": "Script not found: " + script_path, "name": "Error" }}, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[error]:" + err_json + "\n") + sys.stdout.flush() + continue + + mod = entry['mod'] + if not hasattr(mod, 'preprocessor') or not callable(mod.preprocessor): + err_json = json.dumps({{"message": "preprocessor function is missing", "name": "Error"}}, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[error]:" + err_json + "\n") + sys.stdout.flush() + continue + kwargs = json.loads(args_json, strict=False) + pre_args = entry['pre_transform'](kwargs) + preprocessed = mod.preprocessor(**pre_args) + preprocessed_json = json.dumps(preprocessed, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[preprocessed_args]:" + preprocessed_json + "\n") + main_args = entry['transform'](preprocessed if preprocessed else {{}}) + res = mod.main(**main_args) + typ = type(res) + res_json = res_to_json(res, typ) + sys.stdout.write("wm_res[success]:" + res_json + "\n") + except BaseException as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + tb = traceback.format_tb(exc_traceback) + err_json = json.dumps({{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[error]:" + err_json + "\n") + sys.stdout.flush() + continue + + if line.startswith('exec:'): + try: + rest = line[len('exec:'):] + colon_idx = rest.index(':') + script_path = rest[:colon_idx] + args_json = rest[colon_idx + 1:] + + entry = scripts.get(script_path) + if not entry: + err_json = json.dumps({{ "message": "Script not found: " + script_path, "name": "Error" }}, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[error]:" + err_json + "\n") + sys.stdout.flush() + continue + + kwargs = json.loads(args_json, strict=False) + args = entry['transform'](kwargs) + res = entry['mod'].main(**args) + typ = type(res) + res_json = res_to_json(res, typ) + sys.stdout.write("wm_res[success]:" + res_json + "\n") + except BaseException as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + tb = traceback.format_tb(exc_traceback) + err_json = json.dumps({{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[error]:" + err_json + "\n") + sys.stdout.flush() + continue + + sys.stderr.write("Unknown command: " + line + "\n") +"# + ) +} + async fn prepare_wrapper( job_dir: &str, job_flow_step_id: Option<&str>, @@ -1303,7 +1622,7 @@ async fn replace_pip_secret( } } -async fn handle_python_deps( +pub(crate) async fn handle_python_deps( job_dir: &str, requirements_o: Option<&String>, inner_content: &str, @@ -2419,6 +2738,22 @@ pub async fn start_worker( use crate::PyV; tracing::info!("script path: {}", script_path); + let codegen = compute_py_codegen(inner_content, script_path); + + // Write script to proper module path (e.g., f/my/script.py) + let module_dir = format!("{}/{}", job_dir, codegen.dirs); + tokio::fs::create_dir_all(&module_dir).await?; + write_file( + &module_dir, + &format!("{}.py", codegen.module_name), + inner_content, + )?; + + let any_relative_imports = RELATIVE_IMPORT_REGEX.is_match(inner_content); + if any_relative_imports { + let _ = write_file(job_dir, "loader.py", RELATIVE_PYTHON_LOADER)?; + } + let mut mem_peak: i32 = 0; let mut canceled_by: Option = None; let context = variables::get_reserved_variables( @@ -2463,122 +2798,12 @@ pub async fn start_worker( ) .await?; - let ( - import_loader, - import_base64, - import_datetime, - module_dir_dot, - _dirs, - last, - transforms, - spread, - _, - _, - ) = prepare_wrapper(job_dir, None, None, None, inner_content, script_path).await?; - - // Parse preprocessor signature if the script has one - let pre_spread = windmill_parser_py::parse_python_signature( - inner_content, - Some("preprocessor".to_string()), - false, - ) - .ok() - .filter(|sig| !sig.args.is_empty()) - .map(|sig| python_preprocessor_spread(sig, " ")); - { - let postprocessor = get_result_postprocessor(annotations.skip_result_postprocessing); - let indented_transforms = transforms - .lines() - .map(|x| format!(" {}", x)) - .collect::>() - .join("\n"); - - let preprocessor_logic = if let Some(ref pre_spread) = pre_spread { - format!( - r#" - if line.startswith('preprocess:'): - pre_input = line[len('preprocess:'):] - kwargs = json.loads(pre_input, strict=False) - if not hasattr(inner_script, 'preprocessor') or not callable(inner_script.preprocessor): - err_json = json.dumps({{"message": "preprocessor function is missing", "name": "Error"}}, separators=(',', ':'), default=str).replace('\n', '') - sys.stdout.write("wm_res[error]:" + err_json + "\n") - sys.stdout.flush() - continue - try: - pre_args = {{}} - {pre_spread} - for k, v in list(pre_args.items()): - if v == '': - del pre_args[k] - preprocessed_kwargs = inner_script.preprocessor(**pre_args) - preprocessed_json = json.dumps(preprocessed_kwargs, separators=(',', ':'), default=str).replace('\n', '') - sys.stdout.write("wm_res[preprocessed_args]:" + preprocessed_json + "\n") - transform_and_run(preprocessed_kwargs) - except BaseException as e: - exc_type, exc_value, exc_traceback = sys.exc_info() - tb = traceback.format_tb(exc_traceback) - err_json = json.dumps({{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}, separators=(',', ':'), default=str).replace('\n', '') - sys.stdout.write("wm_res[error]:" + err_json + "\n") - sys.stdout.flush() - continue -"# - ) - } else { - String::new() - }; - - let res_to_json_body = python_res_to_json_body(postprocessor); - let wrapper_content: String = format!( - r#" -import json -{import_loader} -{import_base64} -{import_datetime} -import traceback -import sys -from {module_dir_dot} import {last} as inner_script -import re - - -def to_b_64(v: bytes): - import base64 - b64 = base64.b64encode(v) - return b64.decode('ascii') - -def res_to_json(res, typ): -{res_to_json_body} - -def transform_and_run(kwargs): - args = {{}} -{indented_transforms} - {spread} - for k, v in list(args.items()): - if v == '': - del args[k] - res = inner_script.main(**args) - typ = type(res) - res_json = res_to_json(res, typ) - sys.stdout.write("wm_res[success]:" + res_json + "\n") - -replace_invalid_fields = re.compile(r'(?:\bNaN\b|\\u0000|Infinity|\-Infinity)') -sys.stdout.write('start\n') - -for line in sys.stdin: - if line == 'end\n': - break - line = line.strip() - {preprocessor_logic} - kwargs = json.loads(line, strict=False) - try: - transform_and_run(kwargs) - except BaseException as e: - exc_type, exc_value, exc_traceback = sys.exc_info() - tb = traceback.format_tb(exc_traceback) - err_json = json.dumps({{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}, separators=(',', ':'), default=str).replace('\n', '') - sys.stdout.write("wm_res[error]:" + err_json + "\n") - sys.stdout.flush() -"#, + let scripts = [PyScriptEntry { original_path: script_path, codegen: &codegen }]; + let wrapper_content = generate_multi_script_wrapper( + &scripts, + annotations.skip_result_postprocessing, + any_relative_imports, ); write_file(job_dir, "wrapper.py", &wrapper_content)?; } @@ -2632,6 +2857,7 @@ for line in sys.stdin: &mut None, ) .await?; + handle_dedicated_process( &python_path, job_dir, @@ -2704,4 +2930,43 @@ mod tests { // @ is replaced with . assert_eq!(compute_python_module_dir("u/@admin/script"), "u/.admin"); } + + #[test] + fn test_compute_py_codegen_basic_args() { + let code = "def main(x: str, y: int):\n return x\n"; + let cg = compute_py_codegen(code, "f/test/script"); + assert!(cg.spread.contains("args[\"x\"]")); + assert!(cg.spread.contains("args[\"y\"]")); + assert!(cg.transforms.is_empty()); + assert!(cg.pre_spread.is_none()); + assert_eq!(cg.module_name, "script"); + } + + #[test] + fn test_compute_py_codegen_with_datetime_and_bytes() { + let code = "import datetime\n\ndef main(name: str, created_at: datetime.datetime, file: bytes):\n return name\n"; + let cg = compute_py_codegen(code, "f/my/handler"); + assert!(cg.transforms.contains("datetime.fromisoformat")); + assert!(cg.transforms.contains("base64.b64decode")); + assert!(cg.spread.contains("args[\"name\"]")); + assert_eq!(cg.module_dir_dot, "f.my"); + assert_eq!(cg.module_name, "handler"); + } + + #[test] + fn test_compute_py_codegen_star_kwargs() { + let code = "def main(**kwargs):\n return kwargs\n"; + let cg = compute_py_codegen(code, "f/test/star"); + assert_eq!(cg.spread, "args = kwargs"); + } + + #[test] + fn test_compute_py_codegen_with_preprocessor() { + let code = "import datetime\n\ndef main(x: str, ts: datetime.datetime):\n return x\n\ndef preprocessor(input: str, when: datetime.datetime):\n return {\"x\": input, \"ts\": when}\n"; + let cg = compute_py_codegen(code, "f/test/pre"); + assert!(cg.spread.contains("args[\"x\"]")); + assert!(cg.pre_spread.is_some()); + let pre = cg.pre_spread.as_ref().unwrap(); + assert!(pre.contains("pre_args[\"input\"]")); + } } diff --git a/frontend/src/lib/components/DedicatedWorkersSelector.svelte b/frontend/src/lib/components/DedicatedWorkersSelector.svelte index 1ed9d36a7c..94086982fb 100644 --- a/frontend/src/lib/components/DedicatedWorkersSelector.svelte +++ b/frontend/src/lib/components/DedicatedWorkersSelector.svelte @@ -1,6 +1,22 @@ +{#snippet depBadge(dep: string)} + {#if existingDeps.has(dep)} + + {dep} + + + {:else} + + Workspace dependency '{dep}' not found. Create it in workspace settings to enable shared + runners. + + + + {dep} + + {/if} +{/snippet} + +{#snippet tagRow(tag: string, info: SelectedTagInfo | undefined)} +
    +
    + {#if info?.type === 'flow' && info.runners && info.runners.length > 0} + + {:else} +
    + {/if} +
    + {#if info} + {#if info.type === 'flow'} + + {:else} + + {/if} + {info.path} + ({info.workspace}) + + {#if !tagRunnerGroup.has(tag)} + {#if info.workspaceDeps} + {#each info.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {#if info.type === 'flow' && info.runners} + + {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} + + {:else if info.language} + {info.language} + {/if} + {/if} + {:else} + {tag} + {/if} +
    + {#if !disabled} + + {/if} +
    + + {#if info?.type === 'flow' && info.expanded && info.runners} +
    + {#each info.runners as runner (runner.stepId)} +
    + {runner.stepId} + {#if runner.stepSummary} + {runner.stepSummary} + {/if} + + {runner.isInline ? runner.language : runner.scriptPath} + +
    + {/each} +
    + {/if} +
    +{/snippet} +
    - {#if selectedTags.length > 0}
    -
    - {#each selectedTags as tag (tag)} - {@const info = selectedTagsInfo.get(tag)} +
    + + {#each runnerGroups as group (`${group.depName}:${group.language}`)}
    -
    - {#if info?.type === 'flow' && info.runners && info.runners.length > 0} - - {:else} -
    - {/if} -
    -
    - {#if info} - {#if info.type === 'flow'} - - {:else} - - {/if} - {info.path} - ({info.workspace}) - {#if info.type === 'flow' && info.runners} - - {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} - - {:else if info.type === 'script'} - 1 runner - {/if} - {:else} - {tag} - {/if} -
    -
    - {#if !disabled} - - {/if} +
    + + Shared runner + + {@render depBadge(group.depName)} + {group.language}
    +
    + {#each group.tags as tag (tag)} + {@const info = selectedTagsInfo.get(tag)} + {@render tagRow(tag, info)} + {/each} +
    +
    + {/each} - {#if info?.type === 'flow' && info.expanded && info.runners} -
    - {#each info.runners as runner (runner.stepId)} -
    - {runner.stepId} - {#if runner.stepSummary} - {runner.stepSummary} - {/if} - - {runner.isInline ? runner.language : runner.scriptPath} - -
    - {/each} -
    + + {#each standaloneTags as tag (tag)} + {@const info = selectedTagsInfo.get(tag)} +
    + {#if info?.type === 'flow'} + + {:else} + + {/if} + {info?.path ?? tag} + ({info?.workspace ?? ''}) + + {#if info?.workspaceDeps} + {#each info.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {#if info?.type === 'flow' && info.runners} + + {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} + + {:else if info?.language} + {info.language} + {/if} + {#if !disabled} + {/if}
    {/each} @@ -585,15 +824,18 @@ {/if}
    - {runnable.displayName} + {runnable.displayName} {#if runnable.type === 'flow' && runnable.runners} {runnable.runners.length} {/if} - + {#if runnable.workspaceDeps} + {#each runnable.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {runnable.type === 'flow' ? 'flow' : runnable.language} From 7f48704cfdbc2b2b0391f9643a35ed1d7d49c641 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 15:50:45 +0000 Subject: [PATCH 034/153] add missing grants on app_bundles for windmill_user and windmill_admin (#8527) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/migrations/20260325000000_app_bundles_grants.down.sql | 3 +++ backend/migrations/20260325000000_app_bundles_grants.up.sql | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 backend/migrations/20260325000000_app_bundles_grants.down.sql create mode 100644 backend/migrations/20260325000000_app_bundles_grants.up.sql diff --git a/backend/migrations/20260325000000_app_bundles_grants.down.sql b/backend/migrations/20260325000000_app_bundles_grants.down.sql new file mode 100644 index 0000000000..92e34705fe --- /dev/null +++ b/backend/migrations/20260325000000_app_bundles_grants.down.sql @@ -0,0 +1,3 @@ +-- Revoke grants for app_bundles table +REVOKE ALL ON app_bundles FROM windmill_user; +REVOKE ALL ON app_bundles FROM windmill_admin; diff --git a/backend/migrations/20260325000000_app_bundles_grants.up.sql b/backend/migrations/20260325000000_app_bundles_grants.up.sql new file mode 100644 index 0000000000..2ad56563db --- /dev/null +++ b/backend/migrations/20260325000000_app_bundles_grants.up.sql @@ -0,0 +1,3 @@ +-- Add grants for app_bundles table +GRANT ALL ON app_bundles TO windmill_user; +GRANT ALL ON app_bundles TO windmill_admin; From 0bd756839c0261f255111d62088bdaaecb838085 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 25 Mar 2026 18:10:20 +0100 Subject: [PATCH 035/153] feat: SCIM user deprovisioning (active:false) + instance-level user disable (#8484) * [ee] feat: handle active:false in SCIM user PATCH/PUT for deprovisioning Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref for SCIM active:false deprovision fix Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * nit sqlx * [ee] feat: add password.disabled column for SCIM user deactivation Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] feat: enforce password.disabled in auth checks Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] refactor: use scim_deactivated_user table instead of password.disabled Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] fix: apply SCIM filters to deactivated users, add name column Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: add down migration for scim_deactivated_user Co-Authored-By: Claude Opus 4.6 (1M context) * fix: rename migration to avoid timestamp conflict, update sqlx cache Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] refactor: use password.disabled for SCIM deactivation, block login for disabled users Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] feat: show disabled toggle in superadmin user list, add disabled field to API Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add confirmation modal when disabling instance user Co-Authored-By: Claude Opus 4.6 (1M context) * fix: improve disable user confirmation text Co-Authored-By: Claude Opus 4.6 (1M context) * fix: revert toggle state when disable confirmation is cancelled Co-Authored-By: Claude Opus 4.6 (1M context) * fix: properly revert toggle on disable cancel using reset key Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: move disable/enable to dropdown menu, add disabled badge on email Co-Authored-By: Claude Opus 4.6 (1M context) * fix: rename 'Show active users only' to 'Recently active only' to avoid confusion with disabled state Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove accidentally committed gen files Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use .catch() for enable user error handling in dropdown action Co-Authored-By: Claude Opus 4.6 (1M context) * fix: delete tokens on user removal, improve confirmation modal texts Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx cache for non-enterprise code paths Co-Authored-By: Claude Opus 4.6 (1M context) * fix: restore sqlx cache files deleted by incorrect prepare run Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add missing sqlx cache for non-enterprise git sync query Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to a1274aa11a83f608eacc32c0d449ca3527d98c15 This commit updates the EE repository reference after PR #473 was merged in windmill-ee-private. Previous ee-repo-ref: 30f8c53b101b9e25107e793cdc038b0e07061739 New ee-repo-ref: a1274aa11a83f608eacc32c0d449ca3527d98c15 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...1410d32a8d672cfa4929e9e3763c51daa1bc.json} | 10 +- ...024d9826a328bf0416c22daf06fff5ced08f6.json | 14 +++ ...ef901cd3c417b9f3af03f35009213143bd443.json | 28 ++++++ ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json | 15 +++ ...2d14755474cba82b3b388a47585a8bb325b1a.json | 17 ---- ...5b31f0efc6d8ef73f691009c73f833dcee10.json} | 10 +- ...bdc0e1934d67d3f2b14047d434b77d370af21.json | 22 +++++ ...ac9019767074158e0c027988e5b0d51a3656.json} | 4 +- ...504b7b5cb7a39538ab9abeb44f781c711493.json} | 10 +- ...78447d0aa3d143e94e49924ff7ac8b7abf924.json | 22 +++++ backend/ee-repo-ref.txt | 2 +- ...60324000000_scim_deactivated_user.down.sql | 1 + ...0260324000000_scim_deactivated_user.up.sql | 1 + backend/windmill-api-users/src/users.rs | 32 ++++++- backend/windmill-api/openapi.yaml | 5 + .../components/SuperadminSettingsInner.svelte | 96 ++++++++++++++++--- 17 files changed, 248 insertions(+), 43 deletions(-) rename backend/.sqlx/{query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json => query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json} (83%) create mode 100644 backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json create mode 100644 backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json create mode 100644 backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json delete mode 100644 backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json rename backend/.sqlx/{query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json => query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json} (82%) create mode 100644 backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json rename backend/.sqlx/{query-638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6.json => query-daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656.json} (59%) rename backend/.sqlx/{query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json => query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json} (85%) create mode 100644 backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json create mode 100644 backend/migrations/20260324000000_scim_deactivated_user.down.sql create mode 100644 backend/migrations/20260324000000_scim_deactivated_user.up.sql diff --git a/backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json b/backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json similarity index 83% rename from backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json rename to backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json index 53a3863587..d2c85b0e53 100644 --- a/backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json +++ b/backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.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, role_source 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, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -57,6 +57,11 @@ "ordinal": 10, "name": "role_source", "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled", + "type_info": "Bool" } ], "parameters": { @@ -76,8 +81,9 @@ true, null, false, + false, false ] }, - "hash": "05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab" + "hash": "115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc" } diff --git a/backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json b/backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json new file mode 100644 index 0000000000..dc7c41cfd3 --- /dev/null +++ b/backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE email = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6" +} diff --git a/backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json b/backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json new file mode 100644 index 0000000000..d6946e80d0 --- /dev/null +++ b/backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, disabled FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "disabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json b/backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json new file mode 100644 index 0000000000..fc86915946 --- /dev/null +++ b/backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE password SET disabled = $1 WHERE email = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67" +} diff --git a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json deleted file mode 100644 index 25a32e5338..0000000000 --- a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO deployment_metadata (workspace_id, path, script_hash, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a" -} diff --git a/backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json b/backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json similarity index 82% rename from backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json rename to backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json index 6d2382f494..dab14d9f1d 100644 --- a/backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json +++ b/backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.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, 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", + "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, disabled\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": [ { @@ -57,6 +57,11 @@ "ordinal": 10, "name": "role_source", "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled", + "type_info": "Bool" } ], "parameters": { @@ -76,8 +81,9 @@ true, true, false, + false, false ] }, - "hash": "60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce" + "hash": "a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10" } diff --git a/backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json b/backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json new file mode 100644 index 0000000000..a9348dfa8b --- /dev/null +++ b/backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT super_admin FROM password WHERE email = $1 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "super_admin", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21" +} diff --git a/backend/.sqlx/query-638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6.json b/backend/.sqlx/query-daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656.json similarity index 59% rename from backend/.sqlx/query-638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6.json rename to backend/.sqlx/query-daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656.json index d37bc73dad..5b18e66ed4 100644 --- a/backend/.sqlx/query-638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6.json +++ b/backend/.sqlx/query-daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO password (email, login_type, verified, username, name) VALUES ($1, 'saml', true, $2, $3) ON CONFLICT DO NOTHING", + "query": "INSERT INTO password (email, login_type, verified, username, name) VALUES ($1, 'saml', true, $2, $3) ON CONFLICT (email) DO UPDATE SET disabled = false", "describe": { "columns": [], "parameters": { @@ -12,5 +12,5 @@ }, "nullable": [] }, - "hash": "638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6" + "hash": "daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656" } diff --git a/backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json b/backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json similarity index 85% rename from backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json rename to backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json index 2ccf7bfdc8..c1d113ee27 100644 --- a/backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json +++ b/backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.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, role_source 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, disabled FROM password WHERE email = $1", "describe": { "columns": [ { @@ -57,6 +57,11 @@ "ordinal": 10, "name": "role_source", "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled", + "type_info": "Bool" } ], "parameters": { @@ -75,8 +80,9 @@ true, null, false, + false, false ] }, - "hash": "65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9" + "hash": "f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493" } diff --git a/backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json b/backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json new file mode 100644 index 0000000000..c85c557a90 --- /dev/null +++ b/backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT disabled FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "disabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 263ec1de9a..7a74b353a3 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -182943e5ad9bf2a905ccdf07d4e346437fb329a9 +a1274aa11a83f608eacc32c0d449ca3527d98c15 diff --git a/backend/migrations/20260324000000_scim_deactivated_user.down.sql b/backend/migrations/20260324000000_scim_deactivated_user.down.sql new file mode 100644 index 0000000000..1af6e77c62 --- /dev/null +++ b/backend/migrations/20260324000000_scim_deactivated_user.down.sql @@ -0,0 +1 @@ +ALTER TABLE password DROP COLUMN IF EXISTS disabled; diff --git a/backend/migrations/20260324000000_scim_deactivated_user.up.sql b/backend/migrations/20260324000000_scim_deactivated_user.up.sql new file mode 100644 index 0000000000..7410481bdd --- /dev/null +++ b/backend/migrations/20260324000000_scim_deactivated_user.up.sql @@ -0,0 +1 @@ +ALTER TABLE password ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 75db0e2d24..8790a9c541 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -157,6 +157,7 @@ pub struct GlobalUserInfo { operator_only: Option, first_time_user: bool, role_source: String, + disabled: bool, } #[derive(Serialize, Debug)] @@ -213,6 +214,7 @@ pub struct EditUser { pub is_super_admin: Option, pub is_devops: Option, pub name: Option, + pub disabled: Option, } #[derive(Deserialize)] @@ -396,7 +398,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, role_source + 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, disabled FROM password WHERE email IN (SELECT email FROM active_users) ORDER BY super_admin DESC, devops DESC @@ -409,7 +411,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, role_source 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, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ $1 OFFSET $2", per_page as i32, offset as i32 @@ -657,7 +659,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, role_source 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, disabled FROM password WHERE \ email = $1", email ) @@ -680,6 +682,7 @@ async fn global_whoami( operator_only: None, first_time_user: false, role_source: "manual".to_string(), + disabled: false, })) } else { Err(user.unwrap_err()) @@ -1439,6 +1442,22 @@ async fn update_user( .await?; } + if let Some(d) = eu.disabled { + sqlx::query_scalar!( + "UPDATE password SET disabled = $1 WHERE email = $2", + d, + &email_to_update + ) + .execute(&mut *tx) + .await?; + if d { + // Delete all tokens for immediate session revocation + sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_update) + .execute(&mut *tx) + .await?; + } + } + audit_log( &mut *tx, &authed, @@ -1461,6 +1480,9 @@ async fn delete_user( require_super_admin(&db, &authed.email).await?; let mut tx = db.begin().await?; + sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_delete) + .execute(&mut *tx) + .await?; sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete) .execute(&mut *tx) .await?; @@ -1719,7 +1741,7 @@ async fn login( }; let email_w_h: Option<(String, String, bool)> = sqlx::query_as( "SELECT email, password_hash, super_admin FROM password WHERE email = $1 AND login_type = \ - 'password'", + 'password' AND disabled = false", ) .bind(&email) .fetch_optional(&mut *tx) @@ -1808,7 +1830,7 @@ async fn refresh_token( } let super_admin = sqlx::query_scalar!( - "SELECT super_admin FROM password WHERE email = $1", + "SELECT super_admin FROM password WHERE email = $1 AND disabled = false", &authed.email ) .fetch_optional(&mut *tx) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index cb86c2a8b9..7b90c64d12 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -588,6 +588,8 @@ paths: type: boolean name: type: string + disabled: + type: boolean responses: "200": description: user updated @@ -23470,6 +23472,8 @@ components: role_source: type: string enum: ["manual", "instance_group"] + disabled: + type: boolean required: - email @@ -23478,6 +23482,7 @@ components: - verified - first_time_user - role_source + - disabled Flow: allOf: diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 4a1d376f8c..683db57724 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -17,7 +17,7 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import { userStore, workspaceStore } from '$lib/stores' - import { ExternalLink, Pencil, UserMinus, UserPlus } from 'lucide-svelte' + import { Ban, CheckCircle2, ExternalLink, Pencil, UserMinus, UserPlus } from 'lucide-svelte' import DropdownV2 from './DropdownV2.svelte' import Popover from './meltComponents/Popover.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' @@ -67,6 +67,8 @@ let filteredUsers: GlobalUserInfo[] = $state([]) let deleteConfirmedCallback: (() => void) | undefined = $state(undefined) let deleteUserEmail: string = $state('') + let disableConfirmedCallback: (() => void) | undefined = $state(undefined) + let disableUserEmail: string = $state('') let editWrappers: Record = $state({}) let activeOnly = $state(false) @@ -293,9 +295,9 @@ /> @@ -347,13 +349,25 @@ {#if filteredUsers && users} - {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source }, i (email)} - - {email} + {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled }, i (email)} + + +
    + {email} + {#if disabled} + Disabled + {/if} +
    +
    {#if automateUsernameCreation} {#if username} @@ -514,6 +528,39 @@ if (btn instanceof HTMLElement) btn.click() } }, + { + displayName: disabled ? 'Enable' : 'Disable', + icon: disabled ? CheckCircle2 : Ban, + action: () => { + if (!disabled) { + disableUserEmail = email + disableConfirmedCallback = async () => { + try { + await UserService.globalUserUpdate({ + email, + requestBody: { disabled: true } + }) + sendUserToast('User disabled') + listUsers(activeOnly) + } catch (e) { + sendUserToast('Failed to disable user', true) + } + } + } else { + UserService.globalUserUpdate({ + email, + requestBody: { disabled: false } + }) + .then(() => { + sendUserToast('User enabled') + listUsers(activeOnly) + }) + .catch(() => { + sendUserToast('Failed to enable user', true) + }) + } + } + }, { displayName: 'Remove', icon: UserMinus, @@ -578,6 +625,33 @@ }} >
    - Are you sure you want to remove {deleteUserEmail}? + Are you sure you want to remove {deleteUserEmail}? They will be removed from all + workspaces and instance groups, and all their sessions and tokens will be revoked. This action + is irreversible. Their workspace content (scripts, flows, apps) will not be deleted. +
    + + { + disableConfirmedCallback = undefined + listUsers(activeOnly) + }} + on:confirmed={() => { + if (disableConfirmedCallback) { + disableConfirmedCallback() + } + disableConfirmedCallback = undefined + }} +> +
    + Are you sure you want to disable {disableUserEmail}? All their active sessions and + tokens will be revoked immediately. They will be unable to log in until re-enabled. Their + workspace memberships and content will be preserved.
    From ead1ea73af59215a84fb08a58b5d30115acb529f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 17:51:37 +0000 Subject: [PATCH 036/153] sqlx --- ...3e69e4ef8821c6cbf3b4f296b3853d95692af.json | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json diff --git a/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json deleted file mode 100644 index a78e67067f..0000000000 --- a/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af" -} From 9e8d4af458a01d03822144c796330cb3f995723e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 21:12:09 +0000 Subject: [PATCH 037/153] sqlx nits --- ...1c2d14755474cba82b3b388a47585a8bb325b1a.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json diff --git a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json new file mode 100644 index 0000000000..25a32e5338 --- /dev/null +++ b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO deployment_metadata (workspace_id, path, script_hash, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a" +} From 1ff14e3f45e704ebc3250c9c643ca88db15427a3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 21:12:24 +0000 Subject: [PATCH 038/153] sqlx nits --- 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 7a74b353a3..ad995f1326 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a1274aa11a83f608eacc32c0d449ca3527d98c15 +a1274aa11a83f608eacc32c0d449ca3527d98c15 \ No newline at end of file From 2e2dd511f750dc2f592f10965f9747a5c96a0039 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 21:32:00 +0000 Subject: [PATCH 039/153] sqlx nits --- ...3e69e4ef8821c6cbf3b4f296b3853d95692af.json | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json diff --git a/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json new file mode 100644 index 0000000000..a78e67067f --- /dev/null +++ b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af" +} From 5501b7a729a0e5289803097671a7f033570ce7ca Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 21:33:17 +0000 Subject: [PATCH 040/153] replace host docker socket with dind sidecar for isolation (#8531) * feat: replace host docker socket with dind sidecar for isolation Co-Authored-By: Claude Opus 4.6 (1M context) * chore: comment out dind sidecar by default to avoid wasting resources Co-Authored-By: Claude Opus 4.6 (1M context) * fix: enable dind by default, comment out insecure host socket mount Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-worker/src/bash_executor.rs | 32 ++++++++++++++++-- docker-compose.yml | 34 +++++++++++++++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index a9947faaf5..011a72fe15 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -180,6 +180,17 @@ exit $exit_status let _ = write_file(job_dir, "result.out", "")?; let _ = write_file(job_dir, "result2.out", "")?; + // Forward DOCKER_HOST to the bash script when in docker mode so the docker CLI + // connects to the right daemon (e.g. a dind sidecar instead of /var/run/docker.sock) + let docker_envs: Vec<(&str, String)> = if annotation.docker { + ["DOCKER_HOST", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"] + .iter() + .filter_map(|k| std::env::var(k).ok().map(|v| (*k, v))) + .collect() + } else { + vec![] + }; + // Check if this is a regular job (not init or periodic script) // Init/periodic scripts need full system access without isolation let is_regular_job = job @@ -225,6 +236,7 @@ exit $exit_status ) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) + .envs(docker_envs.iter().cloned()) .args(cmd_args) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -255,6 +267,7 @@ exit $exit_status .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .env("HOME", HOME_ENV.as_str()) + .envs(docker_envs.iter().cloned()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -337,6 +350,19 @@ async fn rm_container(client: &bollard::Docker, container_id: &str) { } } +#[cfg(feature = "dind")] +/// Connect to the Docker daemon, respecting DOCKER_HOST if set (e.g. for dind sidecar), +/// otherwise falling back to the default unix socket at /var/run/docker.sock. +fn connect_docker() -> Result { + if std::env::var("DOCKER_HOST").is_ok() { + // DOCKER_HOST is set — use it (e.g. tcp://dind:2375 for docker-in-docker) + bollard::Docker::connect_with_defaults() + } else { + // No DOCKER_HOST — use the unix socket (backward compatible default) + bollard::Docker::connect_with_unix_defaults() + } +} + #[cfg(feature = "dind")] async fn handle_docker_job( job_id: Uuid, @@ -351,7 +377,7 @@ async fn handle_docker_job( ) -> Result, Error> { use crate::job_logger::append_logs_with_compaction; - let client = bollard::Docker::connect_with_unix_defaults().map_err(to_anyhow)?; + let client = connect_docker().map_err(to_anyhow)?; let container_id = job_id.to_string(); let inspected = client.inspect_container(&container_id, None).await; @@ -396,7 +422,7 @@ async fn handle_docker_job( let workspace_id2 = workspace_id.to_string(); let mut killpill_rx = killpill_rx.resubscribe(); let logs = tokio::spawn(async move { - let client = bollard::Docker::connect_with_unix_defaults().map_err(to_anyhow); + let client = connect_docker().map_err(to_anyhow); if let Ok(client) = client { let mut log_stream = client.logs( &ncontainer_id, @@ -464,7 +490,7 @@ async fn handle_docker_job( } }); - let mem_client = bollard::Docker::connect_with_unix_defaults().map_err(to_anyhow); + let mem_client = connect_docker().map_err(to_anyhow); let ncontainer_id = container_id.clone(); let result = run_future_with_polling_update_job_poller( job_id, diff --git a/docker-compose.yml b/docker-compose.yml index 7152b8bf55..ffec3d5dda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,25 @@ services: logging: *default-logging + # Docker-in-Docker sidecar: provides an isolated Docker daemon so user scripts + # can run containers without accessing the host Docker socket. + dind: + image: docker:dind + privileged: true + restart: unless-stopped + environment: + DOCKER_TLS_CERTDIR: "" + volumes: + - dind-data:/var/lib/docker + expose: + - 2375 + healthcheck: + test: ["CMD", "docker", "info"] + interval: 10s + timeout: 5s + retries: 5 + logging: *default-logging + windmill_worker: image: ${WM_IMAGE} pull_policy: always @@ -71,15 +90,23 @@ services: # If running with non-root/non-windmill UID (e.g., user: "1001:1001"), # add: - HOME=/tmp - FAVOR_UNSHARE_PID=true + # Connect to the dind sidecar instead of the host Docker socket + - DOCKER_HOST=tcp://dind:2375 depends_on: db: condition: service_healthy + dind: + condition: service_healthy # to mount the worker folder to debug, KEEP_JOB_DIR=true and mount /tmp/windmill volumes: - # mount the docker socket to allow to run docker containers from within the workers - - /var/run/docker.sock:/var/run/docker.sock - worker_dependency_cache:/tmp/windmill/cache - worker_logs:/tmp/windmill/logs + ## WARNING: mounting the host Docker socket grants user scripts full access to + ## the host Docker daemon, enabling host filesystem access and privilege escalation. + ## Only use this if you fully trust all users who can run scripts. + ## To use it, remove the DOCKER_HOST env var and dind depends_on above, + ## and uncomment the line below: + # - /var/run/docker.sock:/var/run/docker.sock logging: *default-logging @@ -138,8 +165,6 @@ services: # condition: service_healthy # # to mount the worker folder to debug, KEEP_JOB_DIR=true and mount /tmp/windmill # volumes: - # # mount the docker socket to allow to run docker containers from within the workers - # - /var/run/docker.sock:/var/run/docker.sock # - worker_dependency_cache:/tmp/windmill/cache # - worker_logs:/tmp/windmill/logs @@ -216,3 +241,4 @@ volumes: windmill_index: null lsp_cache: null caddy_data: null + dind-data: null From b7475c73094a28f520f798f6cb1a0c6b4807ccb7 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 25 Mar 2026 22:33:39 +0100 Subject: [PATCH 041/153] fix: consider wmill.yaml environments alias in git sync (#8532) --- backend/windmill-common/src/workspaces.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 6dc910e0d4..444df60a0c 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -149,7 +149,7 @@ pub enum ObjectType { WorkspaceDependencies, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28160/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28180/sync-script-to-git-repo-windmill"; #[derive(Serialize, Deserialize, Debug)] pub struct GitRepositorySettings { From 36a81004dcdd9b9faea0402f431f36678063624e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 21:51:51 +0000 Subject: [PATCH 042/153] buffer stdin lines in deno dedicated worker wrapper to prevent chunk splitting (#8533) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-worker/src/deno_executor.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index a0fd9fdde8..3013d10689 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -672,10 +672,15 @@ BigInt.prototype.toJSON = function () {{ console.log('start\n'); const decoder = new TextDecoder(); +let _buffer = ""; for await (const chunk of Deno.stdin.readable) {{ - const lines = decoder.decode(chunk); + _buffer += decoder.decode(chunk, {{ stream: true }}); + const _parts = _buffer.split("\n"); + _buffer = _parts.pop() ?? ""; let exit = false; - for (const line of lines.trim().split("\n")) {{ + for (const _part of _parts) {{ + const line = _part.trim(); + if (!line) continue; if (line === "end") {{ exit = true; break; From 9b3e558d84f15052e9c32695a467f8ef7e4ad1f5 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 25 Mar 2026 22:54:36 +0100 Subject: [PATCH 043/153] feat: add instance setting to enforce workspace prefix for HTTP routes (#8528) * feat: add instance-level setting to enforce workspace prefix for HTTP routes Add `http_route_workspaced_route` instance setting that forces all HTTP routes to use workspace prefix (`/api/r/{workspace_id}/{route}`), mirroring the existing `app_workspaced_route` setting for apps. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: bump http trigger version on setting change to invalidate route cache The route cache is version-based, not TTL-based. Without bumping the version sequence when the instance setting changes, cached routes would continue serving with the old prefix behavior until a route is created/updated/deleted or the server restarts. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: immediately refresh HTTP routers on setting change The route cache polls every 60 seconds, but bumping the version sequence only makes the next poll pick up changes. Explicitly call refresh_routers after the setting reload so routes are rebuilt immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...190fb90df980a19c900452f9b1a19c3620e30.json | 32 ++++++++ backend/src/main.rs | 29 ++++++-- backend/src/monitor.rs | 45 +++++++++++- backend/windmill-api-settings/src/lib.rs | 73 ++++++++++++++++++- .../windmill-common/src/global_settings.rs | 8 ++ .../windmill-common/src/instance_config.rs | 2 + backend/windmill-trigger-http/src/handler.rs | 24 ++++-- backend/windmill-trigger-http/src/lib.rs | 13 ++-- .../src/lib/components/instanceSettings.ts | 10 +++ .../http/RouteEditorConfigSection.svelte | 28 ++++++- .../triggers/http/RoutesGenerator.svelte | 23 +++++- .../(root)/(logged)/routes/+page.svelte | 22 +++++- 12 files changed, 281 insertions(+), 28 deletions(-) create mode 100644 backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json diff --git a/backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json b/backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json new file mode 100644 index 0000000000..aaad5153bd --- /dev/null +++ b/backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n route_path,\n workspace_id,\n http_method::TEXT AS \"http_method!\"\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n AND route_path_key IN (\n SELECT\n route_path_key\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n GROUP BY\n route_path_key, http_method\n HAVING COUNT(*) > 1\n )\n ORDER BY route_path_key\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "route_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "http_method!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30" +} diff --git a/backend/src/main.rs b/backend/src/main.rs index bba51ada6b..ad5b9e14b8 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -44,8 +44,8 @@ use windmill_common::{ CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, - HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, - INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, + INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, @@ -104,10 +104,10 @@ use crate::monitor::{ reload_base_url_setting, reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting, reload_extra_pip_index_url_setting, - reload_hub_api_secret_setting, reload_hub_base_url_setting, - reload_instance_events_webhook_setting, reload_job_default_timeout_setting, - reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, - reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, + reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting, + reload_hub_base_url_setting, reload_instance_events_webhook_setting, + reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, + reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration, }; @@ -1814,6 +1814,23 @@ async fn process_notify_event( tracing::error!(error = %e, "Could not reload app workspaced route setting"); } } + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => { + if let Err(e) = reload_http_route_workspaced_route_setting(db).await { + tracing::error!(error = %e, "Could not reload http route workspaced route setting"); + } + #[cfg(feature = "http_trigger")] + match windmill_api::triggers::http::refresh_routers(db).await { + Ok((true, _)) => { + tracing::info!( + "Refreshed HTTP routers (http workspaced route setting change)" + ); + } + Err(err) => { + tracing::error!("Error refreshing HTTP routers (http workspaced route setting change): {err:#}"); + } + _ => {} + } + } AI_CONFIG_SETTING => { tracing::info!("AI config setting changed, bumping instance AI cache revision"); bump_instance_ai_config_revision(); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 082789ef4f..d7f5784b50 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -88,7 +88,13 @@ use windmill_common::{ MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS, }; -use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING}; +use windmill_common::{ + client::AuthedClient, + global_settings::{ + APP_WORKSPACED_ROUTE_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE, + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + }, +}; #[cfg(feature = "parquet")] use windmill_object_store::reload_object_store_setting; use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload}; @@ -296,6 +302,10 @@ pub async fn initial_load( if let Err(e) = reload_app_workspaced_route_setting(db).await { tracing::error!("Error reloading app workspaced route: {:?}", e) } + + if let Err(e) = reload_http_route_workspaced_route_setting(db).await { + tracing::error!("Error reloading http route workspaced route: {:?}", e) + } } #[cfg(feature = "parquet")] @@ -3390,6 +3400,39 @@ pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()> Ok(()) } +pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> { + let http_route_workspaced_route = + load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?; + + let ws_route = match http_route_workspaced_route { + Some(serde_json::Value::Bool(ws_route)) => ws_route, + None => false, + _ => { + tracing::error!( + "Expected {} to be a boolean got: {:?}. Defaulting to false", + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + http_route_workspaced_route + ); + false + } + }; + + let mut l = HTTP_ROUTE_WORKSPACED_ROUTE.write().await; + + if *l != ws_route { + *l = ws_route; + drop(l); + // Bump the HTTP trigger version so the route cache is rebuilt with + // the updated workspaced_route behavior on the next request. + sqlx::query!("SELECT nextval('http_trigger_version_seq')") + .fetch_one(conn) + .await?; + } else { + *l = ws_route; + } + Ok(()) +} + pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<()> { #[derive(Deserialize)] struct DBOversize { diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index d1b52a8cc3..a1ea80f173 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -45,8 +45,8 @@ use windmill_common::{ global_settings::{ AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, - EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, - WS_BASE_URL_SETTING, + EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, @@ -424,6 +424,74 @@ async fn run_setting_pre_write_hook( } } } + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => { + let serde_json::Value::Bool(workspaced_route) = value else { + return Err(error::Error::BadRequest(format!( + "{} setting expected to be boolean", + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING + ))); + }; + + if !*workspaced_route { + #[derive(Debug, Deserialize, Serialize)] + #[allow(unused)] + struct DuplicateRoute { + route_path: String, + workspace_id: String, + http_method: String, + } + let duplicate_routes = sqlx::query_as!( + DuplicateRoute, + r#" + SELECT + route_path, + workspace_id, + http_method::TEXT AS "http_method!" + FROM + http_trigger + WHERE + workspaced_route IS FALSE + AND route_path_key IN ( + SELECT + route_path_key + FROM + http_trigger + WHERE + workspaced_route IS FALSE + GROUP BY + route_path_key, http_method + HAVING COUNT(*) > 1 + ) + ORDER BY route_path_key + "# + ) + .fetch_all(db) + .await?; + + if !duplicate_routes.is_empty() { + tracing::error!( + "Cannot disable {} setting as duplicate http routes were found: {:?}", + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + &duplicate_routes + ); + + #[derive(Serialize)] + struct ErrorResponse { + error: String, + details: Vec, + } + + let error_response = ErrorResponse { + error: "Duplicate HTTP route paths detected".to_string(), + details: duplicate_routes, + }; + + return Err(error::Error::JsonErr( + serde_json::to_value(error_response).unwrap(), + )); + } + } + } _ => {} } Ok(()) @@ -541,6 +609,7 @@ pub async fn get_global_setting( && key != DISABLE_HUB_SETTING && key != EMAIL_DOMAIN_SETTING && key != APP_WORKSPACED_ROUTE_SETTING + && key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING && key != WS_BASE_URL_SETTING { require_super_admin(&db, &authed.email).await?; diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index a473c06ebc..31aadb8210 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -58,12 +58,20 @@ pub const EMAIL_DOMAIN_SETTING: &str = "email_domain"; pub const OTEL_SETTING: &str = "otel"; pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy"; pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route"; +pub const HTTP_ROUTE_WORKSPACED_ROUTE_SETTING: &str = "http_route_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 INSTANCE_EVENTS_WEBHOOK_SETTING: &str = "instance_events_webhook"; pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries"; +use std::sync::Arc; +use tokio::sync::RwLock; + +lazy_static::lazy_static! { + pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: Arc> = Arc::new(RwLock::new(false)); +} + pub const ENV_SETTINGS: &[&str] = &[ "DISABLE_NSJAIL", "MODE", diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 63dde895fa..c843cc6621 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -237,6 +237,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub app_workspaced_route: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub http_route_workspaced_route: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub no_default_maven: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_tags_per_workspace: Option, diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index c7ca7431fd..f6ca4739da 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -8,6 +8,7 @@ use sqlx::PgConnection; use std::collections::HashSet; use windmill_api_auth::ApiAuthed; use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE; use windmill_common::{ db::UserDB, error::{Error, Result}, @@ -61,11 +62,12 @@ pub async fn route_path_key_exists( .await? .unwrap_or(false) } else { - let route_path_key = match workspaced_route { - Some(true) => { - std::borrow::Cow::Owned(format!("{}/{}", w_id, route_path_key.trim_matches('/'))) - } - _ => std::borrow::Cow::Borrowed(route_path_key), + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; + let effective_workspaced = workspaced_route.unwrap_or(false) || http_route_workspaced; + let route_path_key = if effective_workspaced { + std::borrow::Cow::Owned(format!("{}/{}", w_id, route_path_key.trim_matches('/'))) + } else { + std::borrow::Cow::Borrowed(route_path_key) }; sqlx::query_scalar!( @@ -146,6 +148,10 @@ pub async fn insert_new_trigger_into_db( ) -> Result<()> { require_admin(authed.is_admin, &authed.username)?; + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; + let effective_workspaced = + trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced; + let request_type = trigger.config.request_type; let resolved_edited_by = trigger.base.resolve_edited_by(authed); let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed); @@ -186,7 +192,7 @@ pub async fn insert_new_trigger_into_db( trigger.base.path, trigger.config.route_path, route_path_key, - trigger.config.workspaced_route.unwrap_or(false), + effective_workspaced, trigger.config.authentication_resource_path, trigger.config.wrap_body.unwrap_or(false), trigger.config.raw_string.unwrap_or(false), @@ -445,6 +451,10 @@ impl TriggerCrud for HttpTrigger { let route_path_key = check_if_route_exist(db, &trigger.config, workspace_id, Some(path)).await?; + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; + let effective_workspaced = + trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced; + let request_type = trigger.config.request_type; sqlx::query!( @@ -481,7 +491,7 @@ impl TriggerCrud for HttpTrigger { "#, route_path, &route_path_key, - trigger.config.workspaced_route, + Some(effective_workspaced), trigger.config.wrap_body, trigger.config.raw_string, trigger.config.authentication_resource_path, diff --git a/backend/windmill-trigger-http/src/lib.rs b/backend/windmill-trigger-http/src/lib.rs index f5fd563ab3..f851dfc12e 100644 --- a/backend/windmill-trigger-http/src/lib.rs +++ b/backend/windmill-trigger-http/src/lib.rs @@ -7,6 +7,7 @@ use tokio::sync::{RwLock, RwLockReadGuard}; use windmill_common::{ error::{Error, Result}, flows::Retry, + global_settings::HTTP_ROUTE_WORKSPACED_ROUTE, utils::ExpiringCacheEntry, worker::CLOUD_HOSTED, DB, @@ -273,13 +274,15 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route .await?; let mut router = matchit::Router::new(); + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; for trigger in triggers { - let full_path = if trigger.workspaced_route || *CLOUD_HOSTED { - format!("/{}/{}", trigger.workspace_id, trigger.route_path) - } else { - format!("/{}", trigger.route_path) - }; + let full_path = + if trigger.workspaced_route || *CLOUD_HOSTED || http_route_workspaced { + format!("/{}/{}", trigger.workspace_id, trigger.route_path) + } else { + format!("/{}", trigger.route_path) + }; if trigger.is_static_website { router diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 438c09fdb5..c6096fab7b 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -196,6 +196,16 @@ export const settings: Record = { ee_only: '', hideInQuickSetup: true }, + { + label: 'HTTP route workspace prefix', + description: + 'When enabled HTTP routes will be accessible at /api/r/{workspace_id}/{route} instead of /api/r/{route} allowing you to define same route path in different workspaces without conflict', + key: 'http_route_workspaced_route', + fieldType: 'boolean', + storage: 'setting', + ee_only: '', + hideInQuickSetup: true + }, { label: 'Audit log retention (days)', key: 'audit_log_retention_days', diff --git a/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte b/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte index cbbaef9395..5642ea784a 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte @@ -5,7 +5,7 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import { userStore, workspaceStore } from '$lib/stores' - import { HttpTriggerService } from '$lib/gen' + import { HttpTriggerService, SettingService } from '$lib/gen' // import { page } from '$app/state' import { getHttpRoute } from './utils' import { isCloudHosted } from '$lib/cloud' @@ -106,6 +106,26 @@ let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) let userCanEditConfig = $derived(userIsAdmin || isDraftOnly) // User can edit config if they are admin or if the trigger is a draft which will not be saved + + let globalHttpWorkspacedRoute = $state(false) + + async function loadGlobalHttpWorkspacedRouteSetting() { + try { + const setting = await SettingService.getGlobal({ key: 'http_route_workspaced_route' }) + globalHttpWorkspacedRoute = (setting as boolean) ?? false + } catch (error) { + globalHttpWorkspacedRoute = false + } + } + + loadGlobalHttpWorkspacedRouteSetting() + + $effect.pre(() => { + if (globalHttpWorkspacedRoute && !workspaced_route) { + workspaced_route = true + dirtyRoutePath = true + } + })
    @@ -172,13 +192,15 @@ { workspaced_route = !workspaced_route dirtyRoutePath = true }} options={{ - right: 'Prefix with workspace', + right: globalHttpWorkspacedRoute + ? 'Prefix with workspace (enforced by instance setting)' + : 'Prefix with workspace', rightTooltip: 'Prefixes the route with the workspace ID (e.g., {base_url}/api/r/{workspace_id}/{route}). Note: deploying the HTTP trigger to another workspace updates the route workspace prefix accordingly.', rightDocumentationLink: diff --git a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte index b8f4a39759..1fe13d01d4 100644 --- a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte +++ b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte @@ -1,5 +1,11 @@ + +
    + + +
    + +{#if items === undefined} + +{:else if items.length === 0} +
    + +

    Trashbin is empty

    +

    No recently deleted items.

    +
    +{:else} + + + + Type + Path + Deleted by + Deleted + Expires + Actions + + + {#each items as item (item.id)} + {@const Icon = getKindIcon(item.item_kind)} + + +
    + + {getKindLabel(item.item_kind)} +
    +
    + + {item.item_path} + + + {item.deleted_by} + + + {timeAgo(item.deleted_at)} + + + {timeRemaining(item.expires_at)} + + +
    + + +
    +
    +
    + {/each} +
    +{/if} + + { + deleteConfirmedCallback = undefined + }} + onConfirmed={() => { + if (deleteConfirmedCallback) { + deleteConfirmedCallback() + } + deleteConfirmedCallback = undefined + }} +> +

    This item will be permanently deleted. This action cannot be undone.

    +
    + + { + emptyConfirmOpen = false + }} + onConfirmed={() => { + emptyAll() + emptyConfirmOpen = false + }} +> +

    All items in the trashbin will be permanently deleted. This action cannot be undone.

    +
    diff --git a/frontend/src/lib/components/triggers/DeleteTriggerButton.svelte b/frontend/src/lib/components/triggers/DeleteTriggerButton.svelte index b7fed3d6d0..26b1933de7 100644 --- a/frontend/src/lib/components/triggers/DeleteTriggerButton.svelte +++ b/frontend/src/lib/components/triggers/DeleteTriggerButton.svelte @@ -21,6 +21,7 @@ title={`Are you sure you want to delete this ${trigger?.isDraft ? 'draft' : 'deployed'} trigger ?`} confirmationText="Delete" open={confirmationModalOpen} + trashbin on:canceled={() => { confirmationModalOpen = false }} diff --git a/frontend/src/lib/services/trashService.ts b/frontend/src/lib/services/trashService.ts new file mode 100644 index 0000000000..c1bdac5a23 --- /dev/null +++ b/frontend/src/lib/services/trashService.ts @@ -0,0 +1,69 @@ +import { OpenAPI } from '$lib/gen/core/OpenAPI' +import { request as __request } from '$lib/gen/core/request' + +export type TrashItem = { + id: number + workspace_id: string + item_kind: string + item_path: string + deleted_by: string + deleted_at: string + expires_at: string +} + +export class TrashService { + public static listTrash(data: { + workspace: string + itemKind?: string + page?: number + perPage?: number + }): Promise { + return __request(OpenAPI, { + method: 'GET', + url: '/w/{workspace}/trash/list', + path: { + workspace: data.workspace + }, + query: { + item_kind: data.itemKind, + page: data.page, + per_page: data.perPage + } + }) + } + + public static restoreTrashItem(data: { workspace: string; id: number }): Promise { + return __request(OpenAPI, { + method: 'POST', + url: '/w/{workspace}/trash/restore/{id}', + path: { + workspace: data.workspace, + id: data.id + } + }) + } + + public static permanentlyDeleteTrashItem(data: { + workspace: string + id: number + }): Promise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/w/{workspace}/trash/delete/{id}', + path: { + workspace: data.workspace, + id: data.id + } + }) + } + + public static emptyTrash(data: { workspace: string }): Promise { + return __request(OpenAPI, { + method: 'POST', + url: '/w/{workspace}/trash/empty', + path: { + workspace: data.workspace + } + }) + } +} diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index 600cc45183..b6b1c8c6e5 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -30,13 +30,14 @@ import Toggle from '$lib/components/Toggle.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import type { ResourceType, WorkspaceDeployUISettings } from '$lib/gen' - import { FolderService, OauthService, ResourceService, WorkspaceService, type ListableResource } from '$lib/gen' import { - enterpriseLicense, - userStore, - workspaceStore, - userWorkspaces - } from '$lib/stores' + FolderService, + OauthService, + ResourceService, + WorkspaceService, + type ListableResource + } from '$lib/gen' + import { enterpriseLicense, userStore, workspaceStore, userWorkspaces } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { canWrite, @@ -151,7 +152,12 @@ let folderPresets = $derived([ ...folders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })), ...(resourcesFilterSchema.user_folders_only - ? [{ name: resourcesFilterSchema.user_folders_only.label ?? '?', value: 'user_folders_only:\\ true' }] + ? [ + { + name: resourcesFilterSchema.user_folders_only.label ?? '?', + value: 'user_folders_only:\\ true' + } + ] : []) ]) @@ -577,6 +583,7 @@ open={Boolean(deleteConfirmedCallback)} title="Remove resource" confirmationText="Remove" + trashbin on:canceled={() => { deleteConfirmedCallback = undefined }} diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte index ea48db42cd..e253a21fed 100644 --- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte @@ -74,7 +74,12 @@ let folderPresets = $derived([ ...folders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })), ...(variablesFilterSchema.user_folders_only - ? [{ name: variablesFilterSchema.user_folders_only.label ?? '?', value: 'user_folders_only:\\ true' }] + ? [ + { + name: variablesFilterSchema.user_folders_only.label ?? '?', + value: 'user_folders_only:\\ true' + } + ] : []) ]) let contextualVariables: ContextualVariable[] = $state([]) @@ -576,6 +581,7 @@ {open} title="Remove variable" confirmationText="Remove" + trashbin on:canceled={() => { deleteConfirmedCallback = undefined }} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index db3a140c0b..24900d9076 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -56,6 +56,7 @@ 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 Trashbin from '$lib/components/settings/Trashbin.svelte' import { untrack } from 'svelte' import { getHandlerType } from '$lib/components/triggers/utils' import DucklakeSettings, { @@ -473,17 +474,15 @@ } async function loadSettings(): Promise { - const [settings, copilotSettingsState]: [ - GetSettingsResponse, - GetCopilotSettingsStateResponse - ] = await Promise.all([ - WorkspaceService.getSettings({ - workspace: $workspaceStore! - }), - WorkspaceService.getCopilotSettingsState({ - workspace: $workspaceStore! - }) - ]) + const [settings, copilotSettingsState]: [GetSettingsResponse, GetCopilotSettingsStateResponse] = + await Promise.all([ + WorkspaceService.getSettings({ + workspace: $workspaceStore! + }), + WorkspaceService.getCopilotSettingsState({ + workspace: $workspaceStore! + }) + ]) slack_team_name = settings.slack_name teams_team_id = settings.teams_team_id teams_team_name = settings.teams_team_name @@ -1193,6 +1192,12 @@ label: 'Encryption', aiId: 'workspace-settings-encryption', aiDescription: 'Encryption workspace settings' + }, + { + id: 'trashbin', + label: 'Trashbin', + aiId: 'workspace-settings-trashbin', + aiDescription: 'Trashbin for recently deleted items' } ] } @@ -1927,6 +1932,14 @@ export async function main( saveLabel="Save & Re-encrypt workspace" disabled={!!encryptionKeyValidationError || workspaceReencryptionInProgress} /> + {:else if tab == 'trashbin'} + +
    + +
    {/if}
    From 0885d8c986f13ac210e4db3ad38febe9be391ba4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 11:06:51 +0000 Subject: [PATCH 051/153] feat: mask sensitive values in job logs (#8520) * feat: mask sensitive values (secrets, password args) in job logs Co-Authored-By: Claude Opus 4.6 (1M context) * test: replace artificial unit tests with real integration tests Co-Authored-By: Claude Opus 4.6 (1M context) * test: consolidate into single comprehensive masking test covering 8 scenarios Co-Authored-By: Claude Opus 4.6 (1M context) * feat: show first 3 chars of masked secrets and add security notice Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update masking notice to say "display full value" Co-Authored-By: Claude Opus 4.6 (1M context) * fix: handle poisoned locks, deduplicate notice, mask non-string encrypted args Co-Authored-By: Claude Opus 4.6 (1M context) * perf: snapshot-based masking, one lock per batch instead of per line Co-Authored-By: Claude Opus 4.6 (1M context) * perf: use Aho-Corasick for O(m) single-pass matching regardless of secret count Co-Authored-By: Claude Opus 4.6 (1M context) * fix: track notice in snapshot (no global lock), document snapshot race trade-off Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 2 + .../windmill-api-integration-tests/Cargo.toml | 1 + .../tests/sensitive_log_masking.rs | 465 ++++++++++++++++++ backend/windmill-common/Cargo.toml | 1 + backend/windmill-common/src/lib.rs | 1 + .../src/sensitive_log_masks.rs | 159 ++++++ backend/windmill-store/src/variables.rs | 4 + backend/windmill-worker/src/common.rs | 12 + backend/windmill-worker/src/handle_child.rs | 14 + backend/windmill-worker/src/worker.rs | 4 + 10 files changed, 663 insertions(+) create mode 100644 backend/windmill-api-integration-tests/tests/sensitive_log_masking.rs create mode 100644 backend/windmill-common/src/sensitive_log_masks.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c903490c8d..38095e4037 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16197,6 +16197,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-sqs", "base64 0.22.1", + "futures", "rand 0.9.0", "rdkafka", "reqwest 0.13.1", @@ -16477,6 +16478,7 @@ name = "windmill-common" version = "1.664.0" dependencies = [ "aes-gcm", + "aho-corasick", "anyhow", "async-recursion", "async-stream", diff --git a/backend/windmill-api-integration-tests/Cargo.toml b/backend/windmill-api-integration-tests/Cargo.toml index 35dd64f995..cb4827e857 100644 --- a/backend/windmill-api-integration-tests/Cargo.toml +++ b/backend/windmill-api-integration-tests/Cargo.toml @@ -31,6 +31,7 @@ reqwest.workspace = true tokio.workspace = true anyhow.workspace = true uuid.workspace = true +futures.workspace = true rand.workspace = true rumqttc.workspace = true rdkafka.workspace = true diff --git a/backend/windmill-api-integration-tests/tests/sensitive_log_masking.rs b/backend/windmill-api-integration-tests/tests/sensitive_log_masking.rs new file mode 100644 index 0000000000..fb759ee4b5 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/sensitive_log_masking.rs @@ -0,0 +1,465 @@ +//! Integration tests for sensitive log masking. +//! +//! A single comprehensive test that runs real bun scripts through real workers, +//! covering all masking scenarios: secret variables, non-secret variables, +//! multiple secrets, mid-string secrets, `$encrypted:` args, resources +//! referencing secret variables, and cross-job isolation. +//! +//! Run with: +//! cargo test -p windmill-api-integration-tests --test sensitive_log_masking -- --nocapture +//! +//! Requires: bun runtime, live database (migrations applied by sqlx::test). + +use futures::StreamExt; +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::jobs::{JobPayload, RawCode}; +use windmill_common::scripts::ScriptLang; +use windmill_common::worker::to_raw_value; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +/// Helper: create a variable via the API. +async fn create_variable(port: u16, path: &str, value: &str, is_secret: bool) { + let base = format!("http://localhost:{port}/api/w/test-workspace/variables"); + let resp = authed(client().post(format!("{base}/create"))) + .json(&json!({ + "path": path, + "value": value, + "is_secret": is_secret, + "description": "test variable for log masking" + })) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 201, + "failed to create variable {path}: {}", + resp.text().await.unwrap_or_default() + ); +} + +/// Helper: create a resource via the API. +async fn create_resource(port: u16, path: &str, value: serde_json::Value) { + let base = format!("http://localhost:{port}/api/w/test-workspace/resources"); + let resp = authed(client().post(format!("{base}/create"))) + .json(&json!({ + "path": path, + "value": value, + "resource_type": "object", + "description": "test resource for log masking" + })) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 201, + "failed to create resource {path}: {}", + resp.text().await.unwrap_or_default() + ); +} + +/// Helper: fetch job logs from the job_logs table. +async fn get_job_logs(db: &Pool, job_id: Uuid) -> Option { + sqlx::query_scalar!( + r#"SELECT logs as "logs!" FROM job_logs WHERE job_id = $1"#, + job_id, + ) + .fetch_optional(db) + .await + .unwrap() +} + +/// Helper: push a bun preview job and return its UUID. +async fn push_bun_job(db: &Pool, code: String) -> Uuid { + RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: code, + path: None, + language: ScriptLang::Bun, + lock: None, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + })) + .push(db) + .await +} + +/// Helper: push a bun preview job with encrypted args. +async fn push_bun_job_with_encrypted_arg( + db: &Pool, + code: String, + arg_name: &str, + plaintext_value: &str, +) -> Uuid { + // We need to know the job_id in advance to encrypt with the right key suffix. + let job_id = Uuid::new_v4(); + + // Encrypt the value the same way the frontend does: + // build_crypt_with_key_suffix(db, workspace, root_job_id) + let mc = windmill_common::variables::build_crypt_with_key_suffix( + db, + "test-workspace", + &job_id.to_string(), + ) + .await + .expect("build_crypt_with_key_suffix"); + + // Encrypt the JSON-serialized string value + let json_str = serde_json::to_string(plaintext_value).unwrap(); + let encrypted = windmill_common::variables::encrypt(&mc, &json_str); + let arg_value = format!("$encrypted:{encrypted}"); + + let mut args = std::collections::HashMap::new(); + args.insert(arg_name.to_string(), to_raw_value(&json!(arg_value))); + + RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: code, + path: None, + language: ScriptLang::Bun, + lock: None, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + })) + .job_id(job_id) + .arg(arg_name, json!(arg_value)) + .push(db) + .await +} + +/// Comprehensive test covering all sensitive log masking scenarios in a single +/// test function to amortize server/worker startup cost. +/// +/// Scenarios covered (each as a separate job inside the same worker): +/// 1. Secret variable fetched and logged → masked +/// 2. Non-secret variable fetched and logged → NOT masked (no false positives) +/// 3. Two different secrets fetched and logged in the same job → both masked +/// 4. Secret embedded mid-string (e.g. "token=SECRET&user=bob") → masked +/// 5. Same secret logged 3 times → all occurrences masked +/// 6. `$encrypted:` password arg logged → masked +/// 7. Resource referencing a secret variable via `$var:` → secret masked when logged +/// 8. Cross-job isolation: job A's secret does NOT leak into job B's logs +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_sensitive_log_masking(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // === Setup: create variables and resources === + let secret1 = "alpha_secret_value_9x7k2m"; + let secret2 = "beta_secret_token_4j8n3p"; + let plain_val = "plain_visible_value_12345"; + let encrypted_password = "encrypted_pass_w0rd_zq5r"; + let resource_secret = "resource_db_password_h7t2"; + + create_variable(port, "u/test-user/secret_alpha", secret1, true).await; + create_variable(port, "u/test-user/secret_beta", secret2, true).await; + create_variable(port, "u/test-user/plain_var", plain_val, false).await; + // Secret variable that will be referenced by a resource via $var: + create_variable(port, "u/test-user/res_secret_var", resource_secret, true).await; + // Resource whose "password" field references the secret variable + create_resource( + port, + "u/test-user/db_with_secret", + json!({"host": "db.example.com", "password": "$var:u/test-user/res_secret_var"}), + ) + .await; + + let mut completed = listen_for_completed_jobs(&db).await; + let db2 = db.clone(); + in_test_worker( + db.clone(), + async move { + // ================================================================ + // Scenario 1: Secret variable fetched and console.logged → masked + // ================================================================ + let job1 = push_bun_job( + &db2, + r#"import * as wmill from "windmill-client"; +export async function main() { + const secret = await wmill.getVariable("u/test-user/secret_alpha"); + console.log("The secret value is: " + secret); + return "ok"; +}"# + .into(), + ) + .await; + completed.next().await; + let cjob1 = completed_job(job1, &db2).await; + assert!(cjob1.success, "scenario 1 job failed"); + let logs1 = get_job_logs(&db2, job1).await.expect("scenario 1: no logs"); + + assert!( + !logs1.contains(secret1), + "scenario 1: secret value leaked in logs\nLogs:\n{logs1}" + ); + assert!( + logs1.contains("The secret value is: alp*****"), + "scenario 1: expected masked output with first 3 chars\nLogs:\n{logs1}" + ); + assert!( + logs1.contains("[windmill] secret value was masked for security reasons, use string transformations to display full value"), + "scenario 1: expected security notice\nLogs:\n{logs1}" + ); + + // ================================================================ + // Scenario 2: Non-secret variable → NOT masked (no false positives) + // ================================================================ + let job2 = push_bun_job( + &db2, + r#"import * as wmill from "windmill-client"; +export async function main() { + const val = await wmill.getVariable("u/test-user/plain_var"); + console.log("The plain value is: " + val); + return "ok"; +}"# + .into(), + ) + .await; + completed.next().await; + let cjob2 = completed_job(job2, &db2).await; + assert!(cjob2.success, "scenario 2 job failed"); + let logs2 = get_job_logs(&db2, job2).await.expect("scenario 2: no logs"); + + assert!( + logs2.contains(plain_val), + "scenario 2: plain value should appear unmasked\nLogs:\n{logs2}" + ); + + // ================================================================ + // Scenario 3: Two different secrets fetched in the same job → both masked + // ================================================================ + let job3 = push_bun_job( + &db2, + r#"import * as wmill from "windmill-client"; +export async function main() { + const s1 = await wmill.getVariable("u/test-user/secret_alpha"); + const s2 = await wmill.getVariable("u/test-user/secret_beta"); + console.log("secret1=" + s1); + console.log("secret2=" + s2); + return "ok"; +}"# + .into(), + ) + .await; + completed.next().await; + let cjob3 = completed_job(job3, &db2).await; + assert!(cjob3.success, "scenario 3 job failed"); + let logs3 = get_job_logs(&db2, job3).await.expect("scenario 3: no logs"); + + assert!( + !logs3.contains(secret1), + "scenario 3: secret1 leaked\nLogs:\n{logs3}" + ); + assert!( + !logs3.contains(secret2), + "scenario 3: secret2 leaked\nLogs:\n{logs3}" + ); + assert!( + logs3.contains("secret1=alp*****"), + "scenario 3: secret1 not masked\nLogs:\n{logs3}" + ); + assert!( + logs3.contains("secret2=bet*****"), + "scenario 3: secret2 not masked\nLogs:\n{logs3}" + ); + + // ================================================================ + // Scenario 4: Secret embedded mid-string → masked in place + // ================================================================ + let job4 = push_bun_job( + &db2, + r#"import * as wmill from "windmill-client"; +export async function main() { + const secret = await wmill.getVariable("u/test-user/secret_alpha"); + console.log("token=" + secret + "&user=bob&format=json"); + return "ok"; +}"# + .into(), + ) + .await; + completed.next().await; + let cjob4 = completed_job(job4, &db2).await; + assert!(cjob4.success, "scenario 4 job failed"); + let logs4 = get_job_logs(&db2, job4).await.expect("scenario 4: no logs"); + + assert!( + !logs4.contains(secret1), + "scenario 4: secret leaked mid-string\nLogs:\n{logs4}" + ); + assert!( + logs4.contains("token=alp*****&user=bob&format=json"), + "scenario 4: mid-string masking failed\nLogs:\n{logs4}" + ); + + // ================================================================ + // Scenario 5: Same secret logged 3 times → all occurrences masked + // ================================================================ + let job5 = push_bun_job( + &db2, + r#"import * as wmill from "windmill-client"; +export async function main() { + const secret = await wmill.getVariable("u/test-user/secret_beta"); + console.log("First: " + secret); + console.log("Second: " + secret); + console.log("Third: " + secret); + return "ok"; +}"# + .into(), + ) + .await; + completed.next().await; + let cjob5 = completed_job(job5, &db2).await; + assert!(cjob5.success, "scenario 5 job failed"); + let logs5 = get_job_logs(&db2, job5).await.expect("scenario 5: no logs"); + + assert!( + !logs5.contains(secret2), + "scenario 5: secret leaked\nLogs:\n{logs5}" + ); + let mask_count = logs5.matches("bet*****").count(); + assert!( + mask_count >= 3, + "scenario 5: expected >= 3 masked occurrences, found {mask_count}\nLogs:\n{logs5}" + ); + // Security notice should appear only once even though masking happened 3 times + let notice_count = logs5.matches("[windmill] secret value was masked").count(); + assert_eq!( + notice_count, 1, + "scenario 5: security notice should appear exactly once, found {notice_count}\nLogs:\n{logs5}" + ); + + // ================================================================ + // Scenario 6: $encrypted: password arg → masked when logged + // ================================================================ + let job6 = push_bun_job_with_encrypted_arg( + &db2, + r#"export async function main(password: string) { + console.log("password is: " + password); + return "ok"; +}"# + .into(), + "password", + encrypted_password, + ) + .await; + completed.next().await; + let cjob6 = completed_job(job6, &db2).await; + assert!(cjob6.success, "scenario 6 job failed"); + let logs6 = get_job_logs(&db2, job6).await.expect("scenario 6: no logs"); + + assert!( + !logs6.contains(encrypted_password), + "scenario 6: encrypted password leaked\nLogs:\n{logs6}" + ); + assert!( + logs6.contains("password is: enc*****"), + "scenario 6: encrypted password not masked\nLogs:\n{logs6}" + ); + + // ================================================================ + // Scenario 7: Resource with $var: referencing a secret → masked + // ================================================================ + let job7 = push_bun_job( + &db2, + r#"import * as wmill from "windmill-client"; +export async function main() { + const res = await wmill.getResource("u/test-user/db_with_secret"); + console.log("db password: " + res.password); + console.log("db host: " + res.host); + return "ok"; +}"# + .into(), + ) + .await; + completed.next().await; + let cjob7 = completed_job(job7, &db2).await; + assert!(cjob7.success, "scenario 7 job failed"); + let logs7 = get_job_logs(&db2, job7).await.expect("scenario 7: no logs"); + + assert!( + !logs7.contains(resource_secret), + "scenario 7: resource secret leaked\nLogs:\n{logs7}" + ); + assert!( + logs7.contains("db password: res*****"), + "scenario 7: resource secret not masked\nLogs:\n{logs7}" + ); + // Non-secret field should remain visible + assert!( + logs7.contains("db host: db.example.com"), + "scenario 7: non-secret resource field should be visible\nLogs:\n{logs7}" + ); + + // ================================================================ + // Scenario 8: Cross-job isolation — job A fetches secret_alpha, + // then job B logs "alpha_secret_value_9x7k2m" as a + // literal string (not fetched as a secret). + // Job B should NOT mask it because the secret belongs + // to job A which already completed. + // ================================================================ + // Job A: fetch the secret (registers it) then completes + let job_a = push_bun_job( + &db2, + r#"import * as wmill from "windmill-client"; +export async function main() { + const s = await wmill.getVariable("u/test-user/secret_alpha"); + console.log("fetched secret"); + return "ok"; +}"# + .into(), + ) + .await; + completed.next().await; + let cjob_a = completed_job(job_a, &db2).await; + assert!(cjob_a.success, "scenario 8 job A failed"); + + // Job B: logs the same string as a hardcoded literal (NOT fetched as secret) + // Since job A already completed and unregistered, and job B never + // fetched the secret, it should NOT be masked. + let job_b_code = format!( + r#"export async function main() {{ + console.log("literal value: {secret1}"); + return "ok"; +}}"# + ); + let job_b = push_bun_job(&db2, job_b_code).await; + completed.next().await; + let cjob_b = completed_job(job_b, &db2).await; + assert!(cjob_b.success, "scenario 8 job B failed"); + let logs_b = get_job_logs(&db2, job_b) + .await + .expect("scenario 8 job B: no logs"); + + assert!( + logs_b.contains(secret1), + "scenario 8: job B should show the literal string unmasked (it never fetched a secret)\nLogs:\n{logs_b}" + ); + }, + port, + ) + .await; + + Ok(()) +} diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index a0aa27342d..d593787def 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -56,6 +56,7 @@ tokio-util.workspace = true datafusion = { workspace = true, optional = true} reqwest = { workspace = true } tracing-subscriber = { workspace = true } +aho-corasick = "1" lazy_static.workspace = true tracing-appender.workspace = true gethostname.workspace = true diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 0093f1088f..b5ec518315 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -86,6 +86,7 @@ pub mod schedule; pub mod schema; pub mod scripts; pub mod secret_backend; +pub mod sensitive_log_masks; pub mod server; pub mod ssrf; #[cfg(feature = "private")] diff --git a/backend/windmill-common/src/sensitive_log_masks.rs b/backend/windmill-common/src/sensitive_log_masks.rs new file mode 100644 index 0000000000..8123d6d55b --- /dev/null +++ b/backend/windmill-common/src/sensitive_log_masks.rs @@ -0,0 +1,159 @@ +//! In-memory store for masking sensitive values (secrets, password args) in job logs. +//! +//! Workers run an embedded server in the same process, so we use global state to track: +//! - Which jobs are currently running +//! - Which secret values each job should mask in its stdout +//! +//! When a secret is fetched via `get_value_internal` (embedded server handler), we don't know +//! which job triggered the request (auth is user-based, not job-based), so we register the +//! secret for ALL currently running jobs on this worker process. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::sync::RwLock; +use uuid::Uuid; + +/// Minimum length for a secret to be registered for masking. +/// Short strings (e.g. "true", "1234") would cause too many false positives. +const MIN_SECRET_LENGTH: usize = 8; + +const MASKED_NOTICE: &str = + "[windmill] secret value was masked for security reasons, use string transformations to display full value"; + +lazy_static::lazy_static! { + /// Map of job_id -> set of secret values that should be masked in that job's logs. + static ref SENSITIVE_MASKS: RwLock>> = + RwLock::new(HashMap::new()); + + /// Set of currently running job IDs on this worker process. + static ref RUNNING_JOBS: RwLock> = + RwLock::new(HashSet::new()); + +} + +/// A lock-free snapshot of secrets for a job, taken once per log batch. +/// Uses Aho-Corasick for O(m) multi-pattern matching in a single pass, +/// regardless of the number of secrets registered. +pub struct MaskSnapshot { + /// Aho-Corasick automaton for fast matching. + ac: aho_corasick::AhoCorasick, + /// Replacement strings, indexed to match the automaton's pattern order. + replacements: Vec, + /// Whether the security notice has already been appended for this snapshot. + /// Tracked locally to avoid a global write lock on every masked line. + notice_shown: std::cell::Cell, +} + +impl MaskSnapshot { + /// Mask all secrets in `text`. Returns `Cow::Borrowed` when no match (zero allocation). + /// The Aho-Corasick scan is O(text_len) regardless of how many secrets are registered. + pub fn mask<'a>(&self, text: &'a str) -> Cow<'a, str> { + if text.is_empty() { + return Cow::Borrowed(text); + } + + // Single-pass check + replace using the pre-built automaton + if !self.ac.is_match(text) { + return Cow::Borrowed(text); + } + + let mut result = self.ac.replace_all(text, &self.replacements); + + // Append the notice only once per snapshot (i.e. per batch) + if !self.notice_shown.get() { + self.notice_shown.set(true); + result.push('\n'); + result.push_str(MASKED_NOTICE); + } + + Cow::Owned(result) + } +} + +/// Take a snapshot of the current secrets for a job. Returns `None` if no secrets +/// are registered (the caller can then skip masking entirely for the whole batch). +/// +/// Call this once per log batch in `write_lines`, not per line. +pub fn snapshot(job_id: &Uuid) -> Option { + let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner()); + let secrets = masks.get(job_id)?; + if secrets.is_empty() { + return None; + } + + // Sort longest-first so longer secrets are matched before shorter substrings + let mut sorted: Vec<&String> = secrets.iter().collect(); + sorted.sort_by(|a, b| b.len().cmp(&a.len())); + + let replacements: Vec = sorted + .iter() + .map(|s| { + let prefix: String = s.chars().take(3).collect(); + format!("{}*****", prefix) + }) + .collect(); + + let ac = aho_corasick::AhoCorasickBuilder::new() + .match_kind(aho_corasick::MatchKind::LeftmostLongest) + .build(sorted.iter().map(|s| s.as_str())) + .expect("failed to build aho-corasick automaton"); + + Some(MaskSnapshot { ac, replacements, notice_shown: std::cell::Cell::new(false) }) +} + +/// Register a job as currently running. Call this before `handle_queued_job`. +pub fn register_running_job(job_id: Uuid) { + { + let mut jobs = RUNNING_JOBS.write().unwrap_or_else(|e| e.into_inner()); + jobs.insert(job_id); + } + { + let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner()); + masks.entry(job_id).or_default(); + } +} + +/// Unregister a job when it completes. Removes both the running job entry and its mask set. +pub fn unregister_running_job(job_id: Uuid) { + { + let mut jobs = RUNNING_JOBS.write().unwrap_or_else(|e| e.into_inner()); + jobs.remove(&job_id); + } + { + let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner()); + masks.remove(&job_id); + } +} + +/// Register a secret value for ALL currently running jobs. +/// Used when a secret is fetched via the embedded server (we don't know which job triggered it). +pub fn register_secret_for_all_running_jobs(secret: &str) { + if secret.len() < MIN_SECRET_LENGTH { + return; + } + let jobs = RUNNING_JOBS.read().unwrap_or_else(|e| e.into_inner()); + if jobs.is_empty() { + return; + } + let job_ids: Vec = jobs.iter().copied().collect(); + drop(jobs); + + let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner()); + for job_id in job_ids { + if let Some(set) = masks.get_mut(&job_id) { + set.insert(secret.to_string()); + } + } +} + +/// Register a secret value for a specific job. +/// Used for `$encrypted:` args where we know the job ID. +pub fn register_secret_for_job(job_id: Uuid, secret: &str) { + if secret.len() < MIN_SECRET_LENGTH { + return; + } + let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner()); + if let Some(set) = masks.get_mut(&job_id) { + set.insert(secret.to_string()); + } +} diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 9db4f010a6..964f81809c 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -1115,6 +1115,10 @@ pub async fn get_value_internal<'a>( variable.value }; + if variable.is_secret && !r.is_empty() { + windmill_common::sensitive_log_masks::register_secret_for_all_running_jobs(&r); + } + // Cache the result when explicitly allowed and caching appropriate if allow_cache { cache_variable(&w_id, &path, db_with_opt_authed.email(), r.clone()); diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 0c81c45286..c6d737becb 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -286,6 +286,18 @@ pub async fn transform_json_value( ) .await?; decrypt(&mc, encrypted.to_string()).and_then(|x| { + // Register the raw decrypted string for log masking. + // This covers both string values and their JSON representations + // (numbers, objects, etc.) that could appear in logs. + windmill_common::sensitive_log_masks::register_secret_for_job(job.id, &x); + if let serde_json::Value::String(ref s) = + serde_json::from_str::(&x).unwrap_or_default() + { + // Also register the inner string value (without JSON quotes) + windmill_common::sensitive_log_masks::register_secret_for_job( + job.id, s, + ); + } serde_json::from_str(&x).map_err(|e| { Error::internal_err(format!( "Failed to decrypt '$encrypted:' value: {e}" diff --git a/backend/windmill-worker/src/handle_child.rs b/backend/windmill-worker/src/handle_child.rs index 46cc047bf0..2edaa4f3a1 100644 --- a/backend/windmill-worker/src/handle_child.rs +++ b/backend/windmill-worker/src/handle_child.rs @@ -432,12 +432,26 @@ pub async fn write_lines( let job_id = job_id.clone(); let mut nstream = String::new(); + // Snapshot secrets once per batch — no lock needed per line. + // Trade-off: secrets registered mid-batch (between snapshot and log line) + // won't be masked until the next batch. In practice the async HTTP round-trip + // to fetch a secret completes before the script's log line arrives. + let mask_snapshot = windmill_common::sensitive_log_masks::snapshot(&job_id); + while let Some(line) = read_lines.next().await { match line { Ok(line) => { if line.is_empty() { continue; } + let line = if let Some(ref snap) = mask_snapshot { + match snap.mask(&line) { + std::borrow::Cow::Owned(masked) => masked, + std::borrow::Cow::Borrowed(_) => line, + } + } else { + line + }; if *OTEL_JOB_LOGS { if let Some(otel_suffix) = line.strip_prefix(OTEL_PREFIX) { tracing::event!(tracing::Level::INFO, otel_suffix); diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index bdeea1c076..b75403bef7 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -2745,6 +2745,8 @@ pub async fn run_worker( let arc_job = Arc::new(job); + windmill_common::sensitive_log_masks::register_running_job(arc_job.id); + let span = create_span_with_name(&arc_job, &worker_name, Some(hostname), "job"); let job_result = handle_queued_job( @@ -2844,6 +2846,8 @@ pub async fn run_worker( _ => {} } + windmill_common::sensitive_log_masks::unregister_running_job(job_id); + #[cfg(feature = "prometheus")] if let Some(duration) = _timer.map(|x| x.stop_and_record()) { register_metric( From 55ad0ff5c499c33b766f47c6f32ba5d3eeb14763 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 11:43:26 +0000 Subject: [PATCH 052/153] fix: use resource-level scope overrides during OAuth2 token refresh (#8540) * fix: use resource-level scope overrides during OAuth2 token refresh Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 6db424512b0d02f86489e85f0026581b7637d6e6 This commit updates the EE repository reference after PR #484 was merged in windmill-ee-private. Previous ee-repo-ref: c9277992608537155a9505a089aca91403d91159 New ee-repo-ref: 6db424512b0d02f86489e85f0026581b7637d6e6 Automated by sync-ee-ref workflow. * fix: restore non-enterprise sqlx cache entries deleted by update_sqlx.sh Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update sqlx cache for latest EE changes Co-Authored-By: Claude Opus 4.6 (1M context) * fix: rename migration to avoid timestamp collision with trashbin Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: collapse duplicate match arms and simplify effective_scopes Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...9948556b636b70b818cc31c2d50921a27366.json} | 10 ++++- ...1ce352ad4fb67d36c19dce09d841e36f85dd.json} | 7 ++-- backend/ee-repo-ref.txt | 2 +- .../20260326100000_account_scopes.down.sql | 1 + .../20260326100000_account_scopes.up.sql | 1 + backend/summarized_schema.txt | 2 +- backend/windmill-api/openapi.yaml | 5 +++ backend/windmill-oauth/src/lib.rs | 39 ++++++++++++------- .../src/lib/components/AppConnectInner.svelte | 5 +++ 10 files changed, 52 insertions(+), 22 deletions(-) rename backend/.sqlx/{query-cc269052ffc1e613d7edc31f0f7bb84f6e6301ad1afb028813105a121a69fa7e.json => query-63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366.json} (76%) rename backend/.sqlx/{query-b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391.json => query-870e1c3f0dc1aaa07ac74a2e37721ce352ad4fb67d36c19dce09d841e36f85dd.json} (68%) create mode 100644 backend/migrations/20260326100000_account_scopes.down.sql create mode 100644 backend/migrations/20260326100000_account_scopes.up.sql diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-cc269052ffc1e613d7edc31f0f7bb84f6e6301ad1afb028813105a121a69fa7e.json b/backend/.sqlx/query-63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366.json similarity index 76% rename from backend/.sqlx/query-cc269052ffc1e613d7edc31f0f7bb84f6e6301ad1afb028813105a121a69fa7e.json rename to backend/.sqlx/query-63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366.json index 2354b265a8..0ba2d18257 100644 --- a/backend/.sqlx/query-cc269052ffc1e613d7edc31f0f7bb84f6e6301ad1afb028813105a121a69fa7e.json +++ b/backend/.sqlx/query-63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url FROM account WHERE workspace_id = $1 AND id = $2", + "query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, scopes FROM account WHERE workspace_id = $1 AND id = $2", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "cc_token_url", "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "scopes", + "type_info": "TextArray" } ], "parameters": { @@ -46,8 +51,9 @@ false, true, true, + true, true ] }, - "hash": "cc269052ffc1e613d7edc31f0f7bb84f6e6301ad1afb028813105a121a69fa7e" + "hash": "63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366" } diff --git a/backend/.sqlx/query-b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391.json b/backend/.sqlx/query-870e1c3f0dc1aaa07ac74a2e37721ce352ad4fb67d36c19dce09d841e36f85dd.json similarity index 68% rename from backend/.sqlx/query-b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391.json rename to backend/.sqlx/query-870e1c3f0dc1aaa07ac74a2e37721ce352ad4fb67d36c19dce09d841e36f85dd.json index bda29ecd5f..2b2fe3c8e6 100644 --- a/backend/.sqlx/query-b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391.json +++ b/backend/.sqlx/query-870e1c3f0dc1aaa07ac74a2e37721ce352ad4fb67d36c19dce09d841e36f85dd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9) RETURNING id", + "query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url, scopes) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9, $10) RETURNING id", "describe": { "columns": [ { @@ -19,12 +19,13 @@ "Varchar", "Varchar", "Varchar", - "Text" + "Text", + "TextArray" ] }, "nullable": [ false ] }, - "hash": "b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391" + "hash": "870e1c3f0dc1aaa07ac74a2e37721ce352ad4fb67d36c19dce09d841e36f85dd" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 24741f225a..ce0a80c162 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -6bb0ff0c40300dfc6049f8e027d8161a9f104d50 \ No newline at end of file +6db424512b0d02f86489e85f0026581b7637d6e6 diff --git a/backend/migrations/20260326100000_account_scopes.down.sql b/backend/migrations/20260326100000_account_scopes.down.sql new file mode 100644 index 0000000000..ffff82fdd7 --- /dev/null +++ b/backend/migrations/20260326100000_account_scopes.down.sql @@ -0,0 +1 @@ +ALTER TABLE account DROP COLUMN IF EXISTS scopes; diff --git a/backend/migrations/20260326100000_account_scopes.up.sql b/backend/migrations/20260326100000_account_scopes.up.sql new file mode 100644 index 0000000000..7cb30c29a9 --- /dev/null +++ b/backend/migrations/20260326100000_account_scopes.up.sql @@ -0,0 +1 @@ +ALTER TABLE account ADD COLUMN scopes TEXT[]; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 7380ccce8c..481ff43ea7 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -33,7 +33,7 @@ workspace_key_kind: cloud ## Tables _sqlx_migrations: version(bigint), description(text), installed_on(ts), success(bool), checksum(bytes), execution_time(bigint) -account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), client(char), refresh_error(text), grant_type(char), cc_client_id(char), cc_client_secret(char), cc_token_url(char), mcp_server_url(text) +account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), client(char), refresh_error(text), grant_type(char), cc_client_id(char), cc_client_secret(char), cc_token_url(char), mcp_server_url(text), scopes(text[]) FK: (workspace_id) -> workspace(id) agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char) ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7b90c64d12..6e22d2d330 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4834,6 +4834,11 @@ paths: mcp_server_url: type: string description: "MCP server URL for MCP OAuth token refresh" + scopes: + type: array + items: + type: string + description: "OAuth scopes to use for token refresh. Overrides instance-level scopes." required: - refresh_token - expires_in diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 26d807dac3..d614837237 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -544,14 +544,22 @@ pub async fn exchange_token( grant_type: &str, oauth_client_info: Option<&ClientWithScopes>, http_client: &reqwest::Client, + scopes: Option<&[String]>, ) -> Result { let token_json = match grant_type { - "authorization_code" => client - .exchange_refresh_token(&RefreshToken::from(refresh_token)) - .with_client(http_client) - .execute::() - .await - .map_err(to_anyhow)?, + "authorization_code" | "" => { + let mut request = client.exchange_refresh_token(&RefreshToken::from(refresh_token)); + if let Some(scopes) = scopes { + if !scopes.is_empty() { + request = request.param("scope", scopes.join(" ")); + } + } + request + .with_client(http_client) + .execute::() + .await + .map_err(to_anyhow)? + } "client_credentials" => { let mut token_request = client.exchange_client_credentials(); @@ -569,12 +577,6 @@ pub async fn exchange_token( .await .map_err(to_anyhow)? } - "" | _ if grant_type.is_empty() => client - .exchange_refresh_token(&RefreshToken::from(refresh_token)) - .with_client(http_client) - .execute::() - .await - .map_err(to_anyhow)?, _ => { return Err(Error::BadRequest(format!( "Unsupported grant type: {}", @@ -599,6 +601,7 @@ pub struct OAuthAccountInfo { pub cc_client_id: Option, pub cc_client_secret: Option, pub cc_token_url: Option, + pub scopes: Option>, } /// Refresh an OAuth token and update the database. @@ -615,7 +618,7 @@ pub async fn refresh_token<'c>( ) -> error::Result { let account = sqlx::query_as!( OAuthAccountInfo, - "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url FROM account WHERE workspace_id = $1 AND id = $2", + "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, scopes FROM account WHERE workspace_id = $1 AND id = $2", w_id, id, ) @@ -679,8 +682,15 @@ pub async fn refresh_token_for_account<'c>( oauth_client_info.client.to_owned() }; + // Account-level scopes override instance-level scopes + let effective_scopes = account + .scopes + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(&oauth_client_info.scopes); + if account.grant_type == "client_credentials" { - for scope in oauth_client_info.scopes.iter() { + for scope in effective_scopes.iter() { client.add_scope(scope); } } @@ -699,6 +709,7 @@ pub async fn refresh_token_for_account<'c>( &account.grant_type, Some(&oauth_client_info), http_client, + Some(effective_scopes), ) .await; diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index e1ec93a00f..68698a05db 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -475,6 +475,11 @@ grant_type: valueToken.grant_type || 'authorization_code' } + // Store scopes so token refresh uses the same scopes + if (scopes.length > 0) { + accountData.scopes = scopes + } + // Add client credentials if using client_credentials flow if (useClientCredentials) { accountData.cc_client_id = clientId.trim() From f6208af6739763b08711377f6cd9331e8e546a4a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 11:49:16 +0000 Subject: [PATCH 053/153] chore(main): release 1.665.0 (#8509) * chore(main): release 1.665.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 26 ++ backend/Cargo.lock | 332 +++++++++--------- 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, 209 insertions(+), 179 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e08a1cf181..f9d6640b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [1.665.0](https://github.com/windmill-labs/windmill/compare/v1.664.0...v1.665.0) (2026-03-26) + + +### Features + +* add instance setting to enforce workspace prefix for HTTP routes ([#8528](https://github.com/windmill-labs/windmill/issues/8528)) ([9b3e558](https://github.com/windmill-labs/windmill/commit/9b3e558d84f15052e9c32695a467f8ef7e4ad1f5)) +* add trashbin system for soft-deleting items ([#8519](https://github.com/windmill-labs/windmill/issues/8519)) ([69ce946](https://github.com/windmill-labs/windmill/commit/69ce946241d98ea90bc7135d44ca0c87f928be88)) +* mask sensitive values in job logs ([#8520](https://github.com/windmill-labs/windmill/issues/8520)) ([0885d8c](https://github.com/windmill-labs/windmill/commit/0885d8c986f13ac210e4db3ad38febe9be391ba4)) +* move basic git sync from EE to CE with runtime user count gating ([#8493](https://github.com/windmill-labs/windmill/issues/8493)) ([79d2bd5](https://github.com/windmill-labs/windmill/commit/79d2bd51a00654162754046308d7670242120df6)) +* runner groups for shared-process multi-script dedicated workers ([#8434](https://github.com/windmill-labs/windmill/issues/8434)) ([c28314f](https://github.com/windmill-labs/windmill/commit/c28314f424ea0e04b86565ce88e6c91e0df1a0cf)) +* SCIM user deprovisioning (active:false) + instance-level user disable ([#8484](https://github.com/windmill-labs/windmill/issues/8484)) ([0bd7568](https://github.com/windmill-labs/windmill/commit/0bd756839c0261f255111d62088bdaaecb838085)) +* show groups and notes in flow status viewer ([#8535](https://github.com/windmill-labs/windmill/issues/8535)) ([167084a](https://github.com/windmill-labs/windmill/commit/167084a0ebe73384fa0d31f0b24017a47686a072)) + + +### Bug Fixes + +* auto-generate datatable SDK reference for app mode system prompt ([#8522](https://github.com/windmill-labs/windmill/issues/8522)) ([8a32322](https://github.com/windmill-labs/windmill/commit/8a32322c187ccc60ec7eafb61a9678f267a82282)) +* consider wmill.yaml environments alias in git sync ([#8532](https://github.com/windmill-labs/windmill/issues/8532)) ([b7475c7](https://github.com/windmill-labs/windmill/commit/b7475c73094a28f520f798f6cb1a0c6b4807ccb7)) +* GitHub Enterprise Server support for self-managed GitHub Apps ([#8507](https://github.com/windmill-labs/windmill/issues/8507)) ([935fb44](https://github.com/windmill-labs/windmill/commit/935fb44c848b8bf9430b5600dd3c3bedb2f89efd)) +* raw apps bundle not found during deployment error ([#8515](https://github.com/windmill-labs/windmill/issues/8515)) ([34e3115](https://github.com/windmill-labs/windmill/commit/34e3115bcbd19a8e0b6f483435586a2ab43d0a8e)) +* require admin for workspace encryption key export ([#8523](https://github.com/windmill-labs/windmill/issues/8523)) ([0317668](https://github.com/windmill-labs/windmill/commit/031766808945aefc926f0836d011c0b2a5d2243d)) +* restrict logout redirect to whitelisted domains ([#8524](https://github.com/windmill-labs/windmill/issues/8524)) ([4c8edd5](https://github.com/windmill-labs/windmill/commit/4c8edd5e944d77ed2d41c2b87171c1115c0fdcdc)) +* serve index disk storage sizes from /srch/ endpoint ([#8511](https://github.com/windmill-labs/windmill/issues/8511)) ([e3620e0](https://github.com/windmill-labs/windmill/commit/e3620e074e1bdb46b2b8d732f35a91d300589663)) +* use /apps_raw/get/ redirect URL for raw apps set as workspace default ([#8508](https://github.com/windmill-labs/windmill/issues/8508)) ([85c52e2](https://github.com/windmill-labs/windmill/commit/85c52e2cded10606cc895d0d3b717e13c69bc9b3)) +* use resource-level scope overrides during OAuth2 token refresh ([#8540](https://github.com/windmill-labs/windmill/issues/8540)) ([55ad0ff](https://github.com/windmill-labs/windmill/commit/55ad0ff5c499c33b766f47c6f32ba5d3eeb14763)) + ## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 38095e4037..684fef7714 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -421,7 +421,7 @@ dependencies = [ "arrow-schema", "chrono", "half", - "indexmap 2.11.1", + "indexmap 2.12.0", "lexical-core", "memchr", "num", @@ -2641,13 +2641,14 @@ dependencies = [ [[package]] name = "cron" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740" +checksum = "089df96cf6a25253b4b6b6744d86f91150a3d4df546f31a95def47976b8cba97" dependencies = [ "chrono", "once_cell", - "winnow 0.6.26", + "phf 0.11.3", + "winnow 0.7.15", ] [[package]] @@ -3137,7 +3138,7 @@ dependencies = [ "base64 0.22.1", "half", "hashbrown 0.14.5", - "indexmap 2.11.1", + "indexmap 2.12.0", "libc", "log", "object_store", @@ -3316,7 +3317,7 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr-common", - "indexmap 2.11.1", + "indexmap 2.12.0", "paste", "recursive", "serde_json", @@ -3331,7 +3332,7 @@ checksum = "422ac9cf3b22bbbae8cdf8ceb33039107fde1b5492693168f13bd566b1bcc839" dependencies = [ "arrow", "datafusion-common", - "indexmap 2.11.1", + "indexmap 2.12.0", "itertools 0.14.0", "paste", ] @@ -3485,7 +3486,7 @@ dependencies = [ "datafusion-common", "datafusion-expr", "datafusion-physical-expr", - "indexmap 2.11.1", + "indexmap 2.12.0", "itertools 0.14.0", "log", "recursive", @@ -3508,7 +3509,7 @@ dependencies = [ "datafusion-physical-expr-common", "half", "hashbrown 0.14.5", - "indexmap 2.11.1", + "indexmap 2.12.0", "itertools 0.14.0", "log", "paste", @@ -3570,7 +3571,7 @@ dependencies = [ "futures", "half", "hashbrown 0.14.5", - "indexmap 2.11.1", + "indexmap 2.12.0", "itertools 0.14.0", "log", "parking_lot", @@ -3612,7 +3613,7 @@ dependencies = [ "bigdecimal", "datafusion-common", "datafusion-expr", - "indexmap 2.11.1", + "indexmap 2.12.0", "log", "recursive", "regex", @@ -3730,7 +3731,7 @@ dependencies = [ "deno_media_type", "deno_path_util", "http 1.4.0", - "indexmap 2.11.1", + "indexmap 2.12.0", "log", "once_cell", "parking_lot", @@ -3771,7 +3772,7 @@ dependencies = [ "glob", "ignore", "import_map", - "indexmap 2.11.1", + "indexmap 2.12.0", "jsonc-parser", "log", "percent-encoding", @@ -3812,7 +3813,7 @@ dependencies = [ "deno_path_util", "deno_unsync", "futures", - "indexmap 2.11.1", + "indexmap 2.12.0", "libc", "memoffset", "parking_lot", @@ -4223,7 +4224,7 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "idna", - "indexmap 2.11.1", + "indexmap 2.12.0", "ipnetwork", "k256", "lazy-regex", @@ -4299,7 +4300,7 @@ version = "0.212.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d328067139909aa81522a5d90f119368b541fbddd73ab630e4d9f777865f0d" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.12.0", "proc-macro-rules", "proc-macro2", "quote", @@ -4343,7 +4344,7 @@ dependencies = [ "deno_error", "deno_path_util", "deno_semver", - "indexmap 2.11.1", + "indexmap 2.12.0", "serde", "serde_json", "sys_traits", @@ -4827,12 +4828,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.3" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -5420,11 +5421,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.6" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", + "serde_core", "typeid", ] @@ -6469,7 +6471,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.11.1", + "indexmap 2.12.0", "slab", "tokio", "tokio-util", @@ -6488,7 +6490,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap 2.11.1", + "indexmap 2.12.0", "slab", "tokio", "tokio-util", @@ -7306,7 +7308,7 @@ checksum = "1215d4d92511fbbdaea50e750e91f2429598ef817f02b579158e92803b52c00a" dependencies = [ "boxed_error", "deno_error", - "indexmap 2.11.1", + "indexmap 2.12.0", "log", "percent-encoding", "serde", @@ -7328,13 +7330,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "serde", + "serde_core", ] [[package]] @@ -7640,7 +7643,7 @@ checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ "base64 0.22.1", "js-sys", - "pem 3.0.5", + "pem 3.0.6", "ring 0.17.14", "serde", "serde_json", @@ -7698,7 +7701,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.12.0", ] [[package]] @@ -7789,7 +7792,7 @@ dependencies = [ "jsonpath-rust", "k8s-openapi", "kube-core", - "pem 3.0.5", + "pem 3.0.6", "rustls 0.23.35", "secrecy", "serde", @@ -8648,19 +8651,20 @@ checksum = "b52c1b33ff98142aecea13138bd399b68aa7ab5d9546c300988c345004001eea" [[package]] name = "monostate" -version = "0.1.14" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aafe1be9d0c75642e3e50fedc7ecadf1ef1cbce6eb66462153fc44245343fbee" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" dependencies = [ "monostate-impl", "serde", + "serde_core", ] [[package]] name = "monostate-impl" -version = "0.1.14" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c402a4092d5e204f32c9e155431046831fa712637043c58cb73bc6bc6c9663b5" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", @@ -8730,7 +8734,7 @@ dependencies = [ "lru 0.14.0", "mysql_common", "native-tls", - "pem 3.0.5", + "pem 3.0.6", "percent-encoding", "rand 0.9.0", "serde", @@ -8783,7 +8787,7 @@ dependencies = [ "bitflags 2.9.4", "codespan-reporting", "hexf-parse", - "indexmap 2.11.1", + "indexmap 2.12.0", "log", "num-traits", "rustc-hash 1.1.0", @@ -9074,7 +9078,7 @@ dependencies = [ "dirs-sys 0.4.1", "fancy-regex 0.14.0", "heck 0.5.0", - "indexmap 2.11.1", + "indexmap 2.12.0", "log", "lru 0.12.5", "miette", @@ -9722,9 +9726,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "0218004a4aae742209bee9c3cef05672f6b2708be36a50add8eb613b1f2a4008" dependencies = [ "num-traits", ] @@ -9936,12 +9940,12 @@ dependencies = [ [[package]] name = "pem" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64 0.22.1", - "serde", + "serde_core", ] [[package]] @@ -10021,7 +10025,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.11.1", + "indexmap 2.12.0", ] [[package]] @@ -10456,7 +10460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" dependencies = [ "futures", - "indexmap 2.11.1", + "indexmap 2.12.0", "nix 0.30.1", "tokio", "tracing", @@ -10925,7 +10929,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ - "pem 3.0.5", + "pem 3.0.6", "ring 0.17.14", "rustls-pki-types", "time", @@ -11423,7 +11427,7 @@ dependencies = [ "convert_case 0.10.0", "fnv", "ident_case", - "indexmap 2.11.1", + "indexmap 2.12.0", "proc-macro-crate", "proc-macro2", "quote", @@ -12229,11 +12233,12 @@ dependencies = [ [[package]] name = "serde_bytes" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" dependencies = [ "serde", + "serde_core", ] [[package]] @@ -12269,15 +12274,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.12.0", "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -12291,12 +12297,13 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] @@ -12364,7 +12371,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.1", + "indexmap 2.12.0", "schemars 0.9.0", "schemars 1.2.1", "serde", @@ -12392,7 +12399,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.12.0", "itoa", "ryu", "serde", @@ -12405,7 +12412,7 @@ version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.12.0", "itoa", "libyml", "memchr", @@ -12583,9 +12590,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "simple_asn1" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", @@ -12872,7 +12879,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap 2.11.1", + "indexmap 2.12.0", "log", "memchr", "once_cell", @@ -13272,7 +13279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4740e53eaf68b101203c1df0937d5161a29f3c13bceed0836ddfe245b72dd000" dependencies = [ "anyhow", - "indexmap 2.11.1", + "indexmap 2.12.0", "serde", "serde_json", "swc_cached", @@ -13384,7 +13391,7 @@ checksum = "65f21494e75d0bd8ef42010b47cabab9caaed8f2207570e809f6f4eb51a710d1" dependencies = [ "better_scoped_tls", "bitflags 2.9.4", - "indexmap 2.11.1", + "indexmap 2.12.0", "once_cell", "phf 0.11.3", "rustc-hash 1.1.0", @@ -13453,7 +13460,7 @@ checksum = "76c76d8b9792ce51401d38da0fa62158d61f6d80d16d68fe5b03ce4bf5fba383" dependencies = [ "base64 0.21.7", "dashmap 5.5.3", - "indexmap 2.11.1", + "indexmap 2.12.0", "once_cell", "serde", "sha1", @@ -13493,7 +13500,7 @@ version = "0.134.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "029eec7dd485923a75b5a45befd04510288870250270292fc2c1b3a9e7547408" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.12.0", "num_cpus", "once_cell", "rustc-hash 1.1.0", @@ -13813,7 +13820,7 @@ source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f dependencies = [ "fnv", "nom 7.1.3", - "ordered-float 5.1.0", + "ordered-float 5.2.0", "serde", "serde_json", ] @@ -14459,7 +14466,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.12.0", "serde", "serde_spanned", "toml_datetime 0.6.11", @@ -14472,7 +14479,7 @@ version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.12.0", "toml_datetime 0.7.0", "toml_parser", "winnow 0.7.15", @@ -15254,7 +15261,7 @@ checksum = "97599c400fc79925922b58303e98fcb8fa88f573379a08ddb652e72cbd2e70f6" dependencies = [ "bitflags 2.9.4", "encoding_rs", - "indexmap 2.11.1", + "indexmap 2.12.0", "num-bigint", "serde", "thiserror 1.0.69", @@ -15474,7 +15481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.11.1", + "indexmap 2.12.0", "wasm-encoder", "wasmparser", ] @@ -15510,7 +15517,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.9.4", "hashbrown 0.15.5", - "indexmap 2.11.1", + "indexmap 2.12.0", "semver 1.0.27", ] @@ -15596,7 +15603,7 @@ dependencies = [ "cfg_aliases 0.1.1", "codespan-reporting", "document-features", - "indexmap 2.11.1", + "indexmap 2.12.0", "log", "naga", "once_cell", @@ -15754,7 +15761,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-nats", @@ -15830,7 +15837,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15843,7 +15850,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "argon2", @@ -15879,7 +15886,7 @@ dependencies = [ "hmac", "http 1.4.0", "hyper 1.8.1", - "indexmap 2.11.1", + "indexmap 2.12.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", "lazy_static", @@ -15984,7 +15991,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16007,7 +16014,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16020,7 +16027,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16046,7 +16053,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.664.0" +version = "1.665.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16056,7 +16063,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16073,7 +16080,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16096,7 +16103,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16119,7 +16126,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16135,7 +16142,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16155,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16175,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16189,7 +16196,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-nats", @@ -16218,7 +16225,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16243,7 +16250,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16261,12 +16268,12 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "axum 0.7.9", "http 1.4.0", - "indexmap 2.11.1", + "indexmap 2.12.0", "itertools 0.14.0", "lazy_static", "serde", @@ -16283,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16303,7 +16310,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16333,7 +16340,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16360,7 +16367,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.664.0" +version = "1.665.0" dependencies = [ "lazy_static", "serde", @@ -16372,7 +16379,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.664.0" +version = "1.665.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16396,7 +16403,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16410,7 +16417,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.664.0" +version = "1.665.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16442,7 +16449,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.664.0" +version = "1.665.0" dependencies = [ "chrono", "lazy_static", @@ -16456,7 +16463,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16475,7 +16482,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.664.0" +version = "1.665.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16512,7 +16519,7 @@ dependencies = [ "hex", "hmac", "hyper 1.8.1", - "indexmap 2.11.1", + "indexmap 2.12.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", "lazy_static", @@ -16576,7 +16583,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.664.0" +version = "1.665.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16595,7 +16602,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.664.0" +version = "1.665.0" dependencies = [ "regex", "serde", @@ -16610,7 +16617,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16634,7 +16641,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "futures", @@ -16651,7 +16658,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.664.0" +version = "1.665.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16667,7 +16674,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -16688,7 +16695,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -16719,7 +16726,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-oauth2", @@ -16743,7 +16750,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-stream", @@ -16777,7 +16784,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "futures", @@ -16795,7 +16802,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.664.0" +version = "1.665.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16804,7 +16811,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "lazy_static", @@ -16816,7 +16823,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "serde_json", @@ -16828,7 +16835,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "gosyn", @@ -16840,7 +16847,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "lazy_static", @@ -16852,7 +16859,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "serde_json", @@ -16864,7 +16871,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "nu-parser", @@ -16875,7 +16882,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16886,7 +16893,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16898,7 +16905,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16909,7 +16916,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-recursion", @@ -16931,7 +16938,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "lazy_static", @@ -16945,7 +16952,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16962,7 +16969,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "lazy_static", @@ -16975,7 +16982,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "serde", @@ -16987,7 +16994,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "lazy_static", @@ -17005,7 +17012,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17021,7 +17028,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17037,7 +17044,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "serde", @@ -17048,7 +17055,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-recursion", @@ -17085,7 +17092,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "const_format", @@ -17123,7 +17130,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.664.0" +version = "1.665.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17134,7 +17141,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-recursion", @@ -17163,7 +17170,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17186,7 +17193,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -17219,7 +17226,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -17239,7 +17246,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -17273,7 +17280,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -17308,7 +17315,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -17331,7 +17338,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -17355,7 +17362,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-nats", @@ -17379,7 +17386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -17414,7 +17421,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -17442,7 +17449,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-trait", @@ -17465,7 +17472,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17484,7 +17491,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.664.0" +version = "1.665.0" dependencies = [ "anyhow", "async-once-cell", @@ -17531,7 +17538,7 @@ dependencies = [ "opentelemetry 0.27.1", "opentelemetry-proto 0.29.0", "oracle", - "pem 3.0.5", + "pem 3.0.6", "pep440_rs", "postgres-native-tls 0.5.1", "process-wrap", @@ -17592,7 +17599,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.664.0" +version = "1.665.0" dependencies = [ "bytes", "futures", @@ -18181,15 +18188,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "0.6.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "0.7.15" @@ -18239,7 +18237,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.11.1", + "indexmap 2.12.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -18270,7 +18268,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.9.4", - "indexmap 2.11.1", + "indexmap 2.12.0", "log", "serde", "serde_derive", @@ -18289,7 +18287,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.1", + "indexmap 2.12.0", "log", "semver 1.0.27", "serde", @@ -18570,7 +18568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ "crc32fast", - "indexmap 2.11.1", + "indexmap 2.12.0", "memchr", "typed-path", ] @@ -18581,6 +18579,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zstd" version = "0.13.3" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e1d6dbe9d2..8204082a5b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.664.0" +version = "1.665.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.664.0" +version = "1.665.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 6e22d2d330..2b83550ab2 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.664.0 + version: 1.665.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index e79e14588d..9af7dc3af2 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.664.0"; +export const VERSION = "v1.665.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 8901d5f979..30dd5d17fd 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -68,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.664.0"; +export const VERSION = "1.665.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 3cd78bf5a6..914eb790d1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.664.0", + "version": "1.665.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.664.0", + "version": "1.665.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 19eb2b69b5..dadb95ca8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.664.0", + "version": "1.665.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 13775e4a5d..9dccc5a54f 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.664.0" +wmill = ">=1.665.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 7480b43a1e..d9b0608dd8 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.664.0 + version: 1.665.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 6629cc8034..93de641acd 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.664.0' + ModuleVersion = '1.665.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 8cfc22d3a6..5ae8ca2bbc 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.664.0" +version = "1.665.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 9632ec19a2..50ac3f664a 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.664.0", + "version": "1.665.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 d4023c7e69..7b70788845 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.664.0", + "version": "1.665.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 694b27ca91..433b0ebcf9 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.664.0 +1.665.0 From d7f4b950ce6e966ed1b410e03d48fe96bc036e73 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 12:42:02 +0000 Subject: [PATCH 054/153] fix: pass pre-bound TcpListener to run_server to fix Windows CI test race (#8542) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/src/main.rs | 5 ++++- backend/windmill-api/src/lib.rs | 10 +++++----- backend/windmill-test-utils/src/lib.rs | 7 +++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index ad5b9e14b8..4fe22c517f 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1099,6 +1099,9 @@ Windmill Community Edition {GIT_VERSION} } let addr = SocketAddr::from((server_bind_address, port)); + let listener = tokio::net::TcpListener::bind(addr) + .await + .context("binding main windmill server")?; let (base_internal_tx, base_internal_rx) = tokio::sync::oneshot::channel::(); @@ -1232,7 +1235,7 @@ Windmill Community Edition {GIT_VERSION} db.clone(), index_reader, log_index_reader, - addr, + listener, server_killpill_rx, base_internal_tx, server_mode, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 65ba4cad15..15a674ea37 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -45,8 +45,8 @@ use windmill_common::global_settings::EMAIL_DOMAIN_SETTING; use windmill_common::worker::HUB_CACHE_DIR; use std::fs::DirBuilder; +use std::sync::Arc; use std::time::Duration; -use std::{net::SocketAddr, sync::Arc}; use tokio::sync::RwLock; use tower::ServiceBuilder; use tower_cookies::CookieManagerLayer; @@ -326,7 +326,7 @@ pub async fn run_server( db: DB, job_index_reader: Option, log_index_reader: Option, - addr: SocketAddr, + listener: tokio::net::TcpListener, mut killpill_rx: tokio::sync::broadcast::Receiver<()>, port_tx: tokio::sync::oneshot::Sender, server_mode: bool, @@ -412,6 +412,9 @@ pub async fn run_server( auth_cache: auth_cache.clone(), base_internal_url: _base_internal_url.clone(), }); + let addr = listener + .local_addr() + .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], 0))); if let Err(err) = smtp_server.start_listener_thread(addr).await { tracing::error!("Error starting SMTP server: {err:#}"); } @@ -452,9 +455,6 @@ pub async fn run_server( health::start_health_check_loop(db.clone(), killpill_rx.resubscribe()); } - let listener = tokio::net::TcpListener::bind(addr) - .await - .context("binding main windmill server")?; let port = listener.local_addr().map(|x| x.port()).unwrap_or(8000); let ip = listener .local_addr() diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index ef2bf66513..d9e22eaa84 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -97,14 +97,13 @@ impl ApiServer { async fn start_inner(db: Pool, agent_mode: bool) -> anyhow::Result { let (tx, rx) = tokio::sync::broadcast::channel::<()>(1); - let sock = tokio::net::TcpListener::bind("127.0.0.1:0") + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .map_err(|e| anyhow::anyhow!("failed to bind TCP listener: {}", e))?; - let addr = sock + let addr = listener .local_addr() .map_err(|e| anyhow::anyhow!("failed to get local address: {}", e))?; - drop(sock); let (port_tx, _port_rx) = tokio::sync::oneshot::channel::(); let name = next_worker_name(); tracing::info!("starting api server for name={name}"); @@ -112,7 +111,7 @@ impl ApiServer { db.clone(), None, None, - addr, + listener, rx, port_tx, agent_mode, From e44504c6e93e7a4ee94ced03ab626b79a4fd0754 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:55:10 +0100 Subject: [PATCH 055/153] feat: add PDF input support to AI agent (#8525) * feat: add PDF input support to AI agent with user_attachments field Co-Authored-By: Claude Opus 4.6 (1M context) * test: add integration tests for PDF input and backward compat Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add ContentPart::File variant for PDF support across all providers Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: address review feedback on PDF support - Extract parse_data_url_bytes and mime_to_document_format helpers in Bedrock - Add is_document_mime helper in ai_types for centralized MIME routing - Extract s3_object_to_content_part helper to deduplicate image_handler/openai - Rename AnthropicImageSource to AnthropicBase64Source - Derive Bedrock DocumentFormat from MIME type instead of hardcoding Pdf Co-Authored-By: Claude Opus 4.6 (1M context) * fix: merge user message and attachments into single message for Bedrock Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-common/src/ai_bedrock.rs | 64 ++++++++++--- backend/windmill-common/src/ai_google.rs | 7 ++ backend/windmill-common/src/ai_types.rs | 24 +++++ .../windmill-worker/src/ai/image_handler.rs | 36 +++++-- .../src/ai/providers/anthropic.rs | 22 ++++- .../src/ai/providers/google_ai.rs | 12 +-- .../src/ai/providers/openai.rs | 39 ++++++-- .../windmill-worker/src/ai/query_builder.rs | 2 +- backend/windmill-worker/src/ai/types.rs | 10 +- backend/windmill-worker/src/ai_executor.rs | 49 +++++++--- cli/src/guidance/skills.ts | 2 +- .../copilot/chat/flow/openFlow.json | 2 +- .../copilot/chat/flow/openFlowZod.ts | 6 +- .../src/lib/components/flows/flowInfers.ts | 12 ++- integration_tests/ai_agent_tests/providers.py | 10 +- .../ai_agent_tests/test_document.pdf | 21 ++++ .../ai_agent_tests/test_user_attachments.py | 96 +++++++++++++++++++ openflow.openapi.yaml | 6 +- system_prompts/auto-generated/flow.md | 2 +- system_prompts/auto-generated/prompts.d.ts | 10 +- system_prompts/auto-generated/prompts.ts | 2 +- .../auto-generated/skills/write-flow/SKILL.md | 2 +- 22 files changed, 349 insertions(+), 87 deletions(-) create mode 100644 integration_tests/ai_agent_tests/test_document.pdf create mode 100644 integration_tests/ai_agent_tests/test_user_attachments.py diff --git a/backend/windmill-common/src/ai_bedrock.rs b/backend/windmill-common/src/ai_bedrock.rs index ea817a1e41..9a7c5baeeb 100644 --- a/backend/windmill-common/src/ai_bedrock.rs +++ b/backend/windmill-common/src/ai_bedrock.rs @@ -11,8 +11,9 @@ use aws_config::BehaviorVersion; use aws_credential_types::provider::token::ProvideToken; use aws_credential_types::provider::ProvideCredentials; use aws_sdk_bedrockruntime::types::{ - ContentBlock, ConversationRole, ConverseStreamOutput, ImageBlock, ImageFormat, ImageSource, - InferenceConfiguration, Message, SystemContentBlock, Tool, ToolInputSchema, ToolSpecification, + ContentBlock, ConversationRole, ConverseStreamOutput, DocumentBlock, DocumentFormat, + DocumentSource, ImageBlock, ImageFormat, ImageSource, InferenceConfiguration, Message, + SystemContentBlock, Tool, ToolInputSchema, ToolSpecification, }; use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient; use serde::{Deserialize, Serialize}; @@ -356,13 +357,12 @@ pub fn content_to_text(content: &OpenAIContent) -> String { } } -/// Parse image data URL and extract format and base64 data -fn parse_image_data_url(url: &str) -> Result<(ImageFormat, Vec), Error> { +/// Parse a data URL and extract MIME type and decoded bytes. +fn parse_data_url_bytes(url: &str) -> Result<(String, Vec), Error> { if !url.starts_with("data:") { - return Err(Error::internal_err("Image URL must be a data URL")); + return Err(Error::internal_err("URL must be a data URL")); } - // Parse data:image/png;base64, let base64_start = url .find("base64,") .ok_or_else(|| Error::internal_err("Invalid data URL format"))?; @@ -372,30 +372,51 @@ fn parse_image_data_url(url: &str) -> Result<(ImageFormat, Vec), Error> { .split(';') .next() .and_then(|s| s.strip_prefix("data:")) - .unwrap_or("image/png"); + .unwrap_or("application/octet-stream"); + + let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data) + .map_err(|e| Error::internal_err(format!("Failed to decode base64 data: {}", e)))?; + + Ok((mime_type.to_string(), bytes)) +} + +/// Parse an image data URL and extract ImageFormat and decoded bytes. +fn parse_image_data_url(url: &str) -> Result<(ImageFormat, Vec), Error> { + let (mime_type, bytes) = parse_data_url_bytes(url)?; - // Extract format from MIME type (e.g., "image/png" -> "png") let format_str = mime_type .rsplit_once('/') .map(|(_, format)| format) .unwrap_or("png"); - // Map to ImageFormat enum let format = match format_str { "png" => ImageFormat::Png, "jpeg" | "jpg" => ImageFormat::Jpeg, "gif" => ImageFormat::Gif, "webp" => ImageFormat::Webp, - _ => ImageFormat::Png, // Default to PNG + _ => ImageFormat::Png, }; - // Decode base64 - let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data) - .map_err(|e| Error::internal_err(format!("Failed to decode base64 image: {}", e)))?; - Ok((format, bytes)) } +/// Map a MIME type to a Bedrock DocumentFormat. +fn mime_to_document_format(mime_type: &str) -> DocumentFormat { + match mime_type { + "application/pdf" => DocumentFormat::Pdf, + "text/csv" => DocumentFormat::Csv, + "text/html" => DocumentFormat::Html, + "text/plain" => DocumentFormat::Txt, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { + DocumentFormat::Docx + } + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => { + DocumentFormat::Xlsx + } + _ => DocumentFormat::Pdf, + } +} + /// Convert a ContentPart to Bedrock ContentBlock fn content_part_to_block(part: &ContentPart) -> Result, Error> { match part { @@ -418,8 +439,21 @@ fn content_part_to_block(part: &ContentPart) -> Result, Err Ok(Some(ContentBlock::Image(image_block))) } + ContentPart::File { file } => { + let (mime_type, bytes) = parse_data_url_bytes(&file.file_data)?; + let doc_source = DocumentSource::Bytes(bytes.into()); + let doc_block = DocumentBlock::builder() + .format(mime_to_document_format(&mime_type)) + .name(file.filename.replace('.', "_")) + .source(doc_source) + .build() + .map_err(|e| { + Error::internal_err(format!("Failed to build document block: {}", e)) + })?; + Ok(Some(ContentBlock::Document(doc_block))) + } ContentPart::S3Object { .. } => { - // S3Objects should be converted to ImageUrl before calling this function + // S3Objects should be converted before calling this function Ok(None) } } diff --git a/backend/windmill-common/src/ai_google.rs b/backend/windmill-common/src/ai_google.rs index 5f8255c10a..b76d976a4c 100644 --- a/backend/windmill-common/src/ai_google.rs +++ b/backend/windmill-common/src/ai_google.rs @@ -354,6 +354,13 @@ pub fn convert_content_to_gemini_parts(content: &OpenAIContent) -> Vec { + parse_data_url(&file.file_data).map(|(mime_type, data)| { + GeminiPart::InlineData { + inline_data: GeminiInlineData { mime_type, data }, + } + }) + } // S3Objects are handled by the worker _ => None, }) diff --git a/backend/windmill-common/src/ai_types.rs b/backend/windmill-common/src/ai_types.rs index 707fab0c2a..bb690fe986 100644 --- a/backend/windmill-common/src/ai_types.rs +++ b/backend/windmill-common/src/ai_types.rs @@ -33,6 +33,11 @@ pub enum ContentPart { ImageUrl { image_url: ImageUrlData, }, + /// File content block for OpenAI Chat Completions format (PDFs, etc.) + #[serde(rename = "file")] + File { + file: FileData, + }, #[serde(rename = "s3_object")] S3Object { s3_object: S3Object, @@ -44,6 +49,25 @@ pub struct ImageUrlData { pub url: String, // data:image/png;base64,... or https://... } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct FileData { + pub filename: String, + pub file_data: String, // data:application/pdf;base64,... +} + +/// Check if a MIME type represents a document (as opposed to an image). +pub fn is_document_mime(mime_type: &str) -> bool { + matches!( + mime_type, + "application/pdf" + | "text/csv" + | "text/html" + | "text/plain" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) +} + #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(untagged)] pub enum OpenAIContent { diff --git a/backend/windmill-worker/src/ai/image_handler.rs b/backend/windmill-worker/src/ai/image_handler.rs index 63d8aeaec3..1e30f6bb32 100644 --- a/backend/windmill-worker/src/ai/image_handler.rs +++ b/backend/windmill-worker/src/ai/image_handler.rs @@ -70,6 +70,29 @@ pub async fn download_and_encode_s3_image( Ok((mime_type.to_string(), base64_data)) } +/// Convert an S3Object to the appropriate ContentPart based on MIME type. +pub async fn s3_object_to_content_part( + s3_object: &S3Object, + client: &AuthedClient, + workspace_id: &str, +) -> Result { + let (mime_type, file_bytes) = + download_and_encode_s3_image(s3_object, client, workspace_id).await?; + let data_url = format!("data:{};base64,{}", mime_type, file_bytes); + + if windmill_common::ai_types::is_document_mime(&mime_type) { + let filename = s3_object + .s3 + .rsplit('/') + .next() + .unwrap_or("document.pdf") + .to_string(); + Ok(ContentPart::File { file: FileData { filename, file_data: data_url } }) + } else { + Ok(ContentPart::ImageUrl { image_url: ImageUrlData { url: data_url } }) + } +} + /// Prepare messages for API by converting S3Objects to base64 ImageUrls pub async fn prepare_messages_for_api( messages: &[OpenAIMessage], @@ -92,15 +115,10 @@ pub async fn prepare_messages_for_api( for part in parts { match part { ContentPart::S3Object { s3_object } => { - // Convert S3Object to base64 image URL - let (mime_type, image_bytes) = - download_and_encode_s3_image(s3_object, client, workspace_id) - .await?; - prepared_content.push(ContentPart::ImageUrl { - image_url: ImageUrlData { - url: format!("data:{};base64,{}", mime_type, image_bytes), - }, - }); + prepared_content.push( + s3_object_to_content_part(s3_object, client, workspace_id) + .await?, + ); } other => { // Keep Text and ImageUrl as-is diff --git a/backend/windmill-worker/src/ai/providers/anthropic.rs b/backend/windmill-worker/src/ai/providers/anthropic.rs index 847c706d71..19f539a3d4 100644 --- a/backend/windmill-worker/src/ai/providers/anthropic.rs +++ b/backend/windmill-worker/src/ai/providers/anthropic.rs @@ -92,7 +92,9 @@ pub enum AnthropicRequestContent { cache_control: Option, }, #[serde(rename = "image")] - Image { source: AnthropicImageSource }, + Image { source: AnthropicBase64Source }, + #[serde(rename = "document")] + Document { source: AnthropicBase64Source }, #[serde(rename = "tool_use")] ToolUse { id: String, name: String, input: Box }, #[serde(rename = "tool_result")] @@ -104,9 +106,9 @@ pub enum AnthropicRequestContent { }, } -/// Image source for Anthropic API +/// Base64 source for Anthropic API (used by both Image and Document content blocks) #[derive(Serialize, Debug)] -pub struct AnthropicImageSource { +pub struct AnthropicBase64Source { pub r#type: String, pub media_type: String, pub data: String, @@ -270,10 +272,20 @@ fn convert_content_to_anthropic(content: &Option) -> Vec { - // Handle base64 images if let Some((media_type, data)) = parse_data_url(&image_url.url) { result.push(AnthropicRequestContent::Image { - source: AnthropicImageSource { + source: AnthropicBase64Source { + r#type: "base64".to_string(), + media_type, + data, + }, + }); + } + } + ContentPart::File { file } => { + if let Some((media_type, data)) = parse_data_url(&file.file_data) { + result.push(AnthropicRequestContent::Document { + source: AnthropicBase64Source { r#type: "base64".to_string(), media_type, data, diff --git a/backend/windmill-worker/src/ai/providers/google_ai.rs b/backend/windmill-worker/src/ai/providers/google_ai.rs index 8ce0b5b951..a490bae7bc 100644 --- a/backend/windmill-worker/src/ai/providers/google_ai.rs +++ b/backend/windmill-worker/src/ai/providers/google_ai.rs @@ -84,13 +84,13 @@ impl GoogleAIQueryBuilder { ); } - if let Some(images) = args.images { - for image in images.iter() { - if !image.s3.is_empty() { - let (mime_type, image_bytes) = - download_and_encode_s3_image(image, client, workspace_id).await?; + if let Some(attachments) = args.attachments { + for attachment in attachments.iter() { + if !attachment.s3.is_empty() { + let (mime_type, file_bytes) = + download_and_encode_s3_image(attachment, client, workspace_id).await?; parts.push(GeminiPart::InlineData { - inline_data: GeminiInlineData { mime_type, data: image_bytes }, + inline_data: GeminiInlineData { mime_type, data: file_bytes }, }); } } diff --git a/backend/windmill-worker/src/ai/providers/openai.rs b/backend/windmill-worker/src/ai/providers/openai.rs index 51bb6e3ec3..4b34a27174 100644 --- a/backend/windmill-worker/src/ai/providers/openai.rs +++ b/backend/windmill-worker/src/ai/providers/openai.rs @@ -4,7 +4,7 @@ use serde_json::value::RawValue; use windmill_common::{ai_providers::AIProvider, client::AuthedClient, error::Error}; use crate::ai::{ - image_handler::{download_and_encode_s3_image, prepare_messages_for_api}, + image_handler::{prepare_messages_for_api, s3_object_to_content_part}, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventProcessor}, sse::{OpenAIResponsesSSEParser, SSEParser}, types::*, @@ -103,6 +103,8 @@ pub enum ImageGenerationContent { InputText { text: String }, #[serde(rename = "input_image")] InputImage { image_url: String }, + #[serde(rename = "input_file")] + InputFile { filename: String, file_data: String }, } /// Output content for assistant messages in Responses API @@ -240,6 +242,12 @@ fn convert_content_to_responses_format( image_url: image_url.url.clone(), }) } + ContentPart::File { file } => { + Some(ImageGenerationContent::InputFile { + filename: file.filename.clone(), + file_data: file.file_data.clone(), + }) + } // S3 objects should have been resolved earlier, but handle gracefully ContentPart::S3Object { .. } => None, }) @@ -421,15 +429,26 @@ impl OpenAIQueryBuilder { let mut content = vec![ImageGenerationContent::InputText { text: args.user_message.to_string() }]; - // Add images if provided - if let Some(images) = args.images { - for image in images.iter() { - if !image.s3.is_empty() { - let (mime_type, image_bytes) = - download_and_encode_s3_image(image, client, workspace_id).await?; - content.push(ImageGenerationContent::InputImage { - image_url: format!("data:{};base64,{}", mime_type, image_bytes), - }); + // Add attachments (images, PDFs, etc.) if provided + if let Some(attachments) = args.attachments { + for attachment in attachments.iter() { + if !attachment.s3.is_empty() { + let part = + s3_object_to_content_part(attachment, client, workspace_id).await?; + match part { + ContentPart::File { file } => { + content.push(ImageGenerationContent::InputFile { + filename: file.filename, + file_data: file.file_data, + }); + } + ContentPart::ImageUrl { image_url } => { + content.push(ImageGenerationContent::InputImage { + image_url: image_url.url, + }); + } + _ => {} + } } } } diff --git a/backend/windmill-worker/src/ai/query_builder.rs b/backend/windmill-worker/src/ai/query_builder.rs index c1d0d79a05..27fc424475 100644 --- a/backend/windmill-worker/src/ai/query_builder.rs +++ b/backend/windmill-worker/src/ai/query_builder.rs @@ -28,7 +28,7 @@ pub struct BuildRequestArgs<'a> { pub output_type: &'a OutputType, pub system_prompt: Option<&'a str>, pub user_message: &'a str, - pub images: Option<&'a [S3Object]>, + pub attachments: Option<&'a [S3Object]>, pub has_websearch: bool, } diff --git a/backend/windmill-worker/src/ai/types.rs b/backend/windmill-worker/src/ai/types.rs index 62a13d958a..85528ccec1 100644 --- a/backend/windmill-worker/src/ai/types.rs +++ b/backend/windmill-worker/src/ai/types.rs @@ -26,7 +26,8 @@ use windmill_types::s3::S3Object; // Re-export shared types from windmill_common pub use windmill_common::ai_providers::AIPlatform; pub use windmill_common::ai_types::{ - ContentPart, ImageUrlData, OpenAIContent, OpenAIMessage, ToolDef, ToolDefFunction, UrlCitation, + ContentPart, FileData, ImageUrlData, OpenAIContent, OpenAIMessage, ToolDef, ToolDefFunction, + UrlCitation, }; /// same as OpenAIMessage but with agent_action field included in the serialization @@ -96,7 +97,8 @@ struct AIAgentArgsRaw { max_completion_tokens: Option, output_schema: Option, output_type: Option, - user_images: Option>, + #[serde(alias = "user_images")] + user_attachments: Option>, streaming: Option, max_iterations: Option, memory: Option, @@ -116,7 +118,7 @@ pub struct AIAgentArgs { pub max_completion_tokens: Option, pub output_schema: Option, pub output_type: Option, - pub user_images: Option>, + pub user_attachments: Option>, pub streaming: Option, pub max_iterations: Option, pub memory: Option, @@ -148,7 +150,7 @@ impl From for AIAgentArgs { max_completion_tokens: raw.max_completion_tokens, output_schema: raw.output_schema, output_type: raw.output_type, - user_images: raw.user_images, + user_attachments: raw.user_attachments, streaming: raw.streaming, max_iterations: raw.max_iterations, memory, diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 2c8def4802..594830cebd 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -714,24 +714,45 @@ pub async fn run_agent( } }; - // Add user message if provided and non-empty - if let Some(ref user_message) = args.user_message { - if !user_message.is_empty() { + // Add user message and attachments as a single user message + // (Bedrock requires a text block alongside document blocks in the same message) + { + let has_message = args + .user_message + .as_ref() + .map(|m| !m.is_empty()) + .unwrap_or(false); + let has_attachments = args + .user_attachments + .as_ref() + .map(|a| !a.is_empty()) + .unwrap_or(false); + + if has_message && has_attachments { + let mut parts = vec![ContentPart::Text { + text: args.user_message.clone().unwrap(), + }]; + for attachment in args.user_attachments.as_ref().unwrap() { + if !attachment.s3.is_empty() { + parts.push(ContentPart::S3Object { s3_object: attachment.clone() }); + } + } messages.push(OpenAIMessage { role: "user".to_string(), - content: Some(OpenAIContent::Text(user_message.clone())), + content: Some(OpenAIContent::Parts(parts)), ..Default::default() }); - } - } - - // Add user images if provided - if let Some(ref user_images) = args.user_images { - if !user_images.is_empty() { + } else if has_message { + messages.push(OpenAIMessage { + role: "user".to_string(), + content: Some(OpenAIContent::Text(args.user_message.clone().unwrap())), + ..Default::default() + }); + } else if has_attachments { let mut parts = vec![]; - for image in user_images.iter() { - if !image.s3.is_empty() { - parts.push(ContentPart::S3Object { s3_object: image.clone() }); + for attachment in args.user_attachments.as_ref().unwrap() { + if !attachment.s3.is_empty() { + parts.push(ContentPart::S3Object { s3_object: attachment.clone() }); } } messages.push(OpenAIMessage { @@ -882,7 +903,7 @@ pub async fn run_agent( output_type, system_prompt: args.system_prompt.as_deref(), user_message: args.user_message.as_deref().unwrap_or(""), - images: args.user_images.as_deref(), + attachments: args.user_attachments.as_deref(), has_websearch, }; diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 662c55eb8e..81c87dd05a 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -4359,7 +4359,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, "raw-app": `--- name: raw-app description: MUST use when creating raw apps. diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index 01dc802a69..308b35e416 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlow.json +++ b/frontend/src/lib/components/copilot/chat/flow/openFlow.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"version":"1.624.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"number","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"number","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"number","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps","additionalProperties":{"type":"string"}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","description":"Custom error message shown when stopping"}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind"}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable"]},"access_type":{"type":"string","description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file +{"openapi":"3.0.3","info":{"version":"1.664.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution — modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind"}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.ts b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.ts index 9782c77d41..b7a7cc13f6 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.ts +++ b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.ts @@ -1,6 +1,6 @@ import { z } from "zod" -export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable"]).describe("Type of asset"), "access_type": z.enum(["r","w","rw"]).describe("Access level for this asset").optional(), "alt_access_type": z.enum(["r","w","rw"]).describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration. Use stop_after_if to control when the loop ends"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')") }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_images": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of image references for vision-capable models.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration. Use stop_after_if to control when the loop ends"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')") }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce( (errors, schema) => @@ -20,7 +20,7 @@ export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "i }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task"), "type": z.literal("aiagent"), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type") -export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable"]).describe("Type of asset"), "access_type": z.enum(["r","w","rw"]).describe("Access level for this asset").optional(), "alt_access_type": z.enum(["r","w","rw"]).describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration. Use stop_after_if to control when the loop ends"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')") }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_images": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of image references for vision-capable models.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration. Use stop_after_if to control when the loop ends"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')") }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce( (errors, schema) => @@ -37,7 +37,7 @@ export const flowModuleSchema = z.object({ "id": z.string().describe("Unique ide message: "Invalid input: Should pass single schema", }); } - }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task"), "type": z.literal("aiagent"), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().describe("Custom error message shown when stopping").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") + }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task"), "type": z.literal("aiagent"), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_use": z.boolean().describe("If true, this step's result is deleted after use to save memory").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") export const flowModulesSchema = z.array(flowModuleSchema) diff --git a/frontend/src/lib/components/flows/flowInfers.ts b/frontend/src/lib/components/flows/flowInfers.ts index cfd58ea5ce..321e0536b0 100644 --- a/frontend/src/lib/components/flows/flowInfers.ts +++ b/frontend/src/lib/components/flows/flowInfers.ts @@ -140,10 +140,10 @@ export const AI_AGENT_SCHEMA: Schema = { format: 'json-schema', showExpr: "fields.output_type === 'text'" }, - user_images: { + user_attachments: { type: 'array', description: - 'Array of images to give as input to the AI agent. Requires a configured workspace S3 storage.', + 'Array of files (images or PDFs) to give as input to the AI agent. Requires a configured workspace S3 storage.', items: { type: 'object', resourceType: 's3object' @@ -176,7 +176,7 @@ export const AI_AGENT_SCHEMA: Schema = { 'streaming', 'memory', 'output_schema', - 'user_images', + 'user_attachments', 'max_completion_tokens', 'temperature', 'max_iterations' @@ -186,6 +186,12 @@ export const AI_AGENT_SCHEMA: Schema = { function migrateAiAgentInputTransforms( inputTransforms: Record ): Record { + // Migrate user_images → user_attachments + if ('user_images' in inputTransforms && !('user_attachments' in inputTransforms)) { + inputTransforms.user_attachments = inputTransforms.user_images + delete inputTransforms.user_images + } + // Check if this has the legacy format if ('messages_context_length' in inputTransforms && !('memory' in inputTransforms)) { const legacyValue = inputTransforms.messages_context_length diff --git a/integration_tests/ai_agent_tests/providers.py b/integration_tests/ai_agent_tests/providers.py index afacda5ed1..82bf275436 100644 --- a/integration_tests/ai_agent_tests/providers.py +++ b/integration_tests/ai_agent_tests/providers.py @@ -136,11 +136,13 @@ ALL_PROVIDERS = [ OPENROUTER, ] -# Vision-capable providers for user_images tests +# Vision-capable providers for user_images/user_attachments tests VISION_PROVIDERS = [ - OPENAI, # gpt-4o-mini supports vision - ANTHROPIC, # claude-3 supports vision - GOOGLE_AI, # gemini supports vision + OPENAI, # gpt-4o-mini supports vision + ANTHROPIC, # claude-3 supports vision + GOOGLE_AI, # gemini supports vision + OPENROUTER, # openai-compatible, vision depends on model + BEDROCK, # bedrock converse API supports vision and documents ] diff --git a/integration_tests/ai_agent_tests/test_document.pdf b/integration_tests/ai_agent_tests/test_document.pdf new file mode 100644 index 0000000000..fce978ea5b --- /dev/null +++ b/integration_tests/ai_agent_tests/test_document.pdf @@ -0,0 +1,21 @@ +%PDF-1.4 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>>>>>endobj +4 0 obj<>stream +BT /F1 24 Tf 100 700 Td (Hello PDF World) Tj ET +endstream +endobj +5 0 obj<>endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000266 00000 n +0000000360 00000 n +trailer<> +startxref +431 +%%EOF \ No newline at end of file diff --git a/integration_tests/ai_agent_tests/test_user_attachments.py b/integration_tests/ai_agent_tests/test_user_attachments.py new file mode 100644 index 0000000000..2bf40aa18a --- /dev/null +++ b/integration_tests/ai_agent_tests/test_user_attachments.py @@ -0,0 +1,96 @@ +"""Tests for AI agent user_attachments (PDF) functionality with S3 storage. + +Prerequisites: +- MinIO running on localhost:9000 with bucket 'wmill' +- Test files uploaded to MinIO (test_images/test_image.webp, test_documents/test_document.pdf) +- S3 resource and storage configured in the integration-tests workspace +""" + +import pytest + +from .conftest import AIAgentTestClient, create_ai_agent_flow, TEST_IMAGE_S3_KEY +from .providers import VISION_PROVIDERS, get_provider_ids + +TEST_PDF_S3_KEY = "test_documents/test_document.pdf" + + +class TestUserAttachments: + """Test AI agent with PDF attachments from S3 storage.""" + + @pytest.mark.parametrize( + "provider_config", + VISION_PROVIDERS, + ids=get_provider_ids(VISION_PROVIDERS), + ) + def test_pdf_analysis( + self, + client: AIAgentTestClient, + setup_providers, + provider_config, + ): + """Test that AI can analyze a PDF uploaded to S3.""" + flow_value = create_ai_agent_flow( + provider_input_transform=provider_config["input_transform"], + system_prompt="You are a helpful assistant that reads documents. Be concise.", + include_user_images=True, + ) + + # Run the flow with the PDF (test_document.pdf contains "Hello PDF World") + result = client.run_preview_flow( + flow_value=flow_value, + args={ + "user_message": "What text does this PDF document contain? Reply with just the text.", + "user_images": [ + { + "s3": TEST_PDF_S3_KEY, + "storage": None, + "filename": "test_document.pdf", + } + ], + }, + ) + + assert result is not None + assert isinstance(result, (dict, str)) + result_text = str(result).lower() + assert "hello" in result_text or "pdf" in result_text, ( + f"Expected AI to read PDF content containing 'Hello PDF World', " + f"got: {result}" + ) + print(f"PDF analysis result from {provider_config['name']}: {result}") + + @pytest.mark.parametrize( + "provider_config", + VISION_PROVIDERS, + ids=get_provider_ids(VISION_PROVIDERS), + ) + def test_backward_compat_user_images( + self, + client: AIAgentTestClient, + setup_providers, + provider_config, + ): + """Test that the old user_images field name still works for images.""" + flow_value = create_ai_agent_flow( + provider_input_transform=provider_config["input_transform"], + system_prompt="You are a helpful assistant that describes images. Be concise.", + include_user_images=True, + ) + + result = client.run_preview_flow( + flow_value=flow_value, + args={ + "user_message": "Describe what you see in this image in one sentence.", + "user_images": [ + { + "s3": TEST_IMAGE_S3_KEY, + "storage": None, + "filename": "test_image.webp", + } + ], + }, + ) + + assert result is not None + assert isinstance(result, (dict, str)) + print(f"Backward compat image result from {provider_config['name']}: {result}") diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index d9b0608dd8..44f26b14b4 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1027,13 +1027,13 @@ components: JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape. Supports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc. Example: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] } - user_images: + user_attachments: allOf: - $ref: '#/components/schemas/InputTransform' description: | - Array of image references for vision-capable models. + Array of file references (images or PDFs) for the AI agent. Format: Array<{ bucket: string, key: string }> - S3 object references - Example: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }] + Example: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }] max_completion_tokens: allOf: - $ref: '#/components/schemas/InputTransform' diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index bcf711d50b..695008cbae 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -120,4 +120,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/prompts.d.ts b/system_prompts/auto-generated/prompts.d.ts index 892c597ca6..c2bf0ea46f 100644 --- a/system_prompts/auto-generated/prompts.d.ts +++ b/system_prompts/auto-generated/prompts.d.ts @@ -1,9 +1,9 @@ export declare const SCRIPT_BASE = "# Windmill Script Writing Guide\n\n## General Principles\n\n- Scripts must export a main function (do not call it)\n- Libraries are installed automatically - do not show installation instructions\n- Credentials and configuration are stored in resources and passed as parameters\n- The windmill client (`wmill`) provides APIs for interacting with the platform\n\n## Function Naming\n\n- Main function: `main` (or `preprocessor` for preprocessor scripts)\n- Must be async for TypeScript variants\n\n## Return Values\n\n- Scripts can return any JSON-serializable value\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, Postgres, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\nThe preprocessor receives a single parameter called `event`.\n"; -export declare const FLOW_BASE = "# Windmill Flow Building Guide\n\n## CLI Commands\n\nCreate a folder ending with `.flow` and add a YAML file with the flow definition.\nFor rawscript modules, use `!inline path/to/script.ts` for the content key.\nAfter writing, tell the user they can run:\n- `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`)\n- `wmill sync push` - Deploy to Windmill\n\nDo NOT run these commands yourself. Instead, inform the user that they should run them.\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step\n- `results.step_id.property` - Access specific property from previous step output\n- `flow_input.iter.value` - Current item when inside a for-loop\n- `flow_input.iter.index` - Current index when inside a for-loop\n\n## Input Transforms\n\nEvery rawscript module needs `input_transforms` to map function parameters to values:\n\nStatic transform (fixed value):\n{\"param_name\": {\"type\": \"static\", \"value\": \"fixed_string\"}}\n\nJavaScript transform (dynamic expression):\n{\"param_name\": {\"type\": \"javascript\", \"expr\": \"results.previous_step.data\"}}\n\n## Resource References\n\n- For flow inputs: Use type `\"object\"` with format `\"resource-{type}\"` (e.g., `\"resource-postgresql\"`)\n- For step inputs: Use static value `\"$res:path/to/resource\"`\n\n## Failure Handler\n\nExecutes when any step fails. Has access to error details:\n\n- `error.message` - Error message\n- `error.step_id` - ID of failed step\n- `error.name` - Error name\n- `error.stack` - Stack trace\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\nTo accept an S3 object as flow input:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"object\",\n \"format\": \"resource-s3_object\",\n \"description\": \"File to process\"\n }\n }\n}\n```\n\n## Using Resources in Flows\n\nOn Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.\n\n### As Flow Input\n\nIn the flow schema, set the property type to `\"object\"` with format `\"resource-{type}\"`:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"database\": {\n \"type\": \"object\",\n \"format\": \"resource-postgresql\",\n \"description\": \"Database connection\"\n }\n }\n}\n```\n\n### As Step Input (Static Reference)\n\nReference a specific resource using `$res:` prefix:\n\n```json\n{\n \"database\": {\n \"type\": \"static\",\n \"value\": \"$res:f/folder/my_database\"\n }\n}\n```\n"; -export declare const SDK_TYPESCRIPT = "# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * @deprecated Use runScriptByPath or runScriptByHash instead\n */\nasync runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead\n */\nasync runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @deprecated use setState instead\n */\nasync setInternalState(state: any): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the internal state\n * @deprecated use getState instead\n */\nasync getInternalState(): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[]): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * @deprecated use getResumeUrls instead\n */\ngetResumeEndpoints(approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Get email from workspace username\n * This method is particularly useful for apps that require the email address of the viewer.\n * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app.\n * @param username\n * @returns email address\n */\nasync usernameToEmail(username: string): Promise\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (s3://storage/key) or record\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\nasync step(name: string, fn: () => T | Promise): Promise\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage\n * URLs before calling this function.\n * \n * @example\n * const urls = await step(\"urls\", () => getResumeUrls());\n * await step(\"notify\", () => sendEmail(urls.approvalPage));\n * const { value, approver } = await waitForApproval({ timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n */\ndatatable(name: string = \"main\"): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n"; -export declare const SDK_PYTHON = "# Python SDK (wmill)\n\nImport: import wmill\n\ndef get_mocked_api() -> Optional[dict]\n\n# Get the HTTP client instance.\n# \n# Returns:\n# Configured httpx.Client for API requests\ndef get_client() -> httpx.Client\n\n# Make an HTTP GET request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.get\n# \n# Returns:\n# HTTP response object\ndef get(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Make an HTTP POST request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.post\n# \n# Returns:\n# HTTP response object\ndef post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Create a new authentication token.\n# \n# Args:\n# duration: Token validity duration (default: 1 day)\n# \n# Returns:\n# New authentication token string\ndef create_token(duration = dt.timedelta(days=1)) -> str\n\n# Create a script job and return its job id.\n# \n# .. deprecated:: Use run_script_by_path_async or run_script_by_hash_async instead.\ndef run_script_async(path: str = None, hash_: str = None, args: dict = None, scheduled_in_secs: int = None) -> str\n\n# Create a script job by path and return its job id.\ndef run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None) -> str\n\n# Create a script job by hash and return its job id.\ndef run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None) -> str\n\n# Create a flow job and return its job id.\ndef run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True) -> str\n\n# Run script synchronously and return its result.\n# \n# .. deprecated:: Use run_script_by_path or run_script_by_hash instead.\ndef run_script(path: str = None, hash_: str = None, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False) -> Any\n\n# Run script by path synchronously and return its result.\ndef run_script_by_path(path: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False) -> Any\n\n# Run script by hash synchronously and return its result.\ndef run_script_by_hash(hash_: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False) -> Any\n\n# Run a script on the current worker without creating a job\ndef run_inline_script_preview(content: str, language: str, args: dict = None) -> Any\n\n# Wait for a job to complete and return its result.\n# \n# Args:\n# job_id: ID of the job to wait for\n# timeout: Maximum time to wait (seconds or timedelta)\n# verbose: Enable verbose logging\n# cleanup: Register cleanup handler to cancel job on exit\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result when completed\n# \n# Raises:\n# TimeoutError: If timeout is reached\n# Exception: If job fails\ndef wait_job(job_id, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False)\n\n# Cancel a specific job by ID.\n# \n# Args:\n# job_id: UUID of the job to cancel\n# reason: Optional reason for cancellation\n# \n# Returns:\n# Response message from the cancel endpoint\ndef cancel_job(job_id: str, reason: str = None) -> str\n\n# Cancel currently running executions of the same script.\ndef cancel_running() -> dict\n\n# Get job details by ID.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job details dictionary\ndef get_job(job_id: str) -> dict\n\n# Get the root job ID for a flow hierarchy.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Root job ID\ndef get_root_job_id(job_id: str | None = None) -> dict\n\n# Get an OIDC JWT token for authentication to external services.\n# \n# Args:\n# audience: Token audience (e.g., \"vault\", \"aws\")\n# expires_in: Optional expiration time in seconds\n# \n# Returns:\n# JWT token string\ndef get_id_token(audience: str, expires_in: int | None = None) -> str\n\n# Get the status of a job.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job status: \"RUNNING\", \"WAITING\", or \"COMPLETED\"\ndef get_job_status(job_id: str) -> JobStatus\n\n# Get the result of a completed job.\n# \n# Args:\n# job_id: UUID of the completed job\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result\ndef get_result(job_id: str, assert_result_is_not_none: bool = True) -> Any\n\n# Get a variable value by path.\n# \n# Args:\n# path: Variable path in Windmill\n# \n# Returns:\n# Variable value as string\ndef get_variable(path: str) -> str\n\n# Set a variable value by path, creating it if it doesn't exist.\n# \n# Args:\n# path: Variable path in Windmill\n# value: Variable value to set\n# is_secret: Whether the variable should be secret (default: False)\ndef set_variable(path: str, value: str, is_secret: bool = False) -> None\n\n# Get a resource value by path.\n# \n# Args:\n# path: Resource path in Windmill\n# none_if_undefined: Return None instead of raising if not found\n# interpolated: if variables and resources are fully unrolled\n# \n# Returns:\n# Resource value dictionary or None\ndef get_resource(path: str, none_if_undefined: bool = False, interpolated: bool = True) -> dict | None\n\n# Set a resource value by path, creating it if it doesn't exist.\n# \n# Args:\n# value: Resource value to set\n# path: Resource path in Windmill\n# resource_type: Resource type for creation\ndef set_resource(value: Any, path: str, resource_type: str)\n\n# List resources from Windmill workspace.\n# \n# Args:\n# resource_type: Optional resource type to filter by (e.g., \"postgresql\", \"mysql\", \"s3\")\n# page: Optional page number for pagination\n# per_page: Optional number of results per page\n# \n# Returns:\n# List of resource dictionaries\ndef list_resources(resource_type: str = None, page: int = None, per_page: int = None) -> list[dict]\n\n# Set the workflow state.\n# \n# Args:\n# value: State value to set\n# path: Optional state resource path override.\ndef set_state(value: Any, path: str | None = None) -> None\n\n# Get the workflow state.\n# \n# Args:\n# path: Optional state resource path override.\n# \n# Returns:\n# State value or None if not set\ndef get_state(path: str | None = None) -> Any\n\n# Set job progress percentage (0-99).\n# \n# Args:\n# value: Progress percentage\n# job_id: Job ID (defaults to current WM_JOB_ID)\ndef set_progress(value: int, job_id: Optional[str] = None)\n\n# Get job progress percentage.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Progress value (0-100) or None if not set\ndef get_progress(job_id: Optional[str] = None) -> Any\n\n# Set the user state of a flow at a given key\ndef set_flow_user_state(key: str, value: Any) -> None\n\n# Get the user state of a flow at a given key\ndef get_flow_user_state(key: str) -> Any\n\n# Get the Windmill server version.\n# \n# Returns:\n# Version string\ndef version()\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Load a file from the workspace s3 bucket and returns its content as bytes.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# my_obj_content = client.load_s3_file(s3_obj)\n# file_content = my_obj_content.decode(\"utf-8\")\n# '''\ndef load_s3_file(s3object: S3Object | str, s3_resource_path: str | None) -> bytes\n\n# Load a file from the workspace s3 bucket and returns the bytes stream.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# with wmill.load_s3_file_reader(s3object, s3_resource_path) as file_reader:\n# print(file_reader.read())\n# '''\ndef load_s3_file_reader(s3object: S3Object | str, s3_resource_path: str | None) -> BufferedReader\n\n# Write a file to the workspace S3 bucket\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# \n# # for an in memory bytes array:\n# file_content = b'Hello Windmill!'\n# client.write_s3_file(s3_obj, file_content)\n# \n# # for a file:\n# with open(\"my_file.txt\", \"rb\") as my_file:\n# client.write_s3_file(s3_obj, my_file)\n# '''\ndef write_s3_file(s3object: S3Object | str | None, file_content: BufferedReader | bytes, s3_resource_path: str | None, content_type: str | None = None, content_disposition: str | None = None) -> S3Object\n\n# Permanently delete a file from the workspace S3 bucket.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# client.delete_s3_object(s3_obj)\n# '''\ndef delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = None) -> None\n\n# Sign S3 objects for use by anonymous users in public apps.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# \n# Returns:\n# List of signed S3 objects\ndef sign_s3_objects(s3_objects: list[S3Object | str]) -> list[S3Object]\n\n# Sign a single S3 object for use by anonymous users in public apps.\n# \n# Args:\n# s3_object: S3 object to sign\n# \n# Returns:\n# Signed S3 object\ndef sign_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Generate presigned public URLs for an array of S3 objects.\n# If an S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# base_url: Optional base URL for the presigned URLs (defaults to WM_BASE_URL)\n# \n# Returns:\n# List of signed public URLs\n# \n# Example:\n# >>> s3_objs = [S3Object(s3=\"/path/to/file1.txt\"), S3Object(s3=\"/path/to/file2.txt\")]\n# >>> urls = client.get_presigned_s3_public_urls(s3_objs)\ndef get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None) -> list[str]\n\n# Generate a presigned public URL for an S3 object.\n# If the S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_object: S3 object to sign\n# base_url: Optional base URL for the presigned URL (defaults to WM_BASE_URL)\n# \n# Returns:\n# Signed public URL\n# \n# Example:\n# >>> s3_obj = S3Object(s3=\"/path/to/file.txt\")\n# >>> url = client.get_presigned_s3_public_url(s3_obj)\ndef get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None) -> str\n\n# Get the current user information.\n# \n# Returns:\n# User details dictionary\ndef whoami() -> dict\n\n# Get the current user information (alias for whoami).\n# \n# Returns:\n# User details dictionary\ndef user() -> dict\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef state_path() -> str\n\n# Get the workflow state.\n# \n# Returns:\n# State value or None if not set\ndef state() -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state_pickle(value: Any, path: str = 'state.pickle') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state_pickle(path: str = 'state.pickle') -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state(value: Any, path: str = 'state.json') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state(path: str = 'state.json') -> None\n\n# Get URLs needed for resuming a flow after suspension.\n# \n# Args:\n# approver: Optional approver name\n# flow_level: If True, generate resume URLs for the parent flow instead of the\n# specific step. This allows pre-approvals that can be consumed by any later\n# suspend step in the same flow.\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_resume_urls(approver: str = None, flow_level: bool = None) -> dict\n\n# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n# \n# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the \"Advanced -> Suspend -> Form\" functionality.\n# Learn more at: https://www.windmill.dev/docs/flows/flow_approval#form\n# \n# :param slack_resource_path: The path to the Slack resource in Windmill.\n# :type slack_resource_path: str\n# :param channel_id: The Slack channel ID where the approval request will be sent.\n# :type channel_id: str\n# :param message: Optional custom message to include in the Slack approval request.\n# :type message: str, optional\n# :param approver: Optional user ID or name of the approver for the request.\n# :type approver: str, optional\n# :param default_args_json: Optional dictionary defining or overriding the default arguments for form fields.\n# :type default_args_json: dict, optional\n# :param dynamic_enums_json: Optional dictionary overriding the enum default values of enum form fields.\n# :type dynamic_enums_json: dict, optional\n# \n# :raises Exception: If the function is not called within a flow or flow preview.\n# :raises Exception: If the required flow job or flow step environment variables are not set.\n# \n# :return: None\n# \n# **Usage Example:**\n# >>> client.request_interactive_slack_approval(\n# ... slack_resource_path=\"/u/alex/my_slack_resource\",\n# ... channel_id=\"admins-slack-channel\",\n# ... message=\"Please approve this request\",\n# ... approver=\"approver123\",\n# ... default_args_json={\"key1\": \"value1\", \"key2\": 42},\n# ... dynamic_enums_json={\"foo\": [\"choice1\", \"choice2\"], \"bar\": [\"optionA\", \"optionB\"]},\n# ... )\n# \n# **Notes:**\n# - This function must be executed within a Windmill flow or flow preview.\n# - The function checks for required environment variables (`WM_FLOW_JOB_ID`, `WM_FLOW_STEP_ID`) to ensure it is run in the appropriate context.\ndef request_interactive_slack_approval(slack_resource_path: str, channel_id: str, message: str = None, approver: str = None, default_args_json: dict = None, dynamic_enums_json: dict = None) -> None\n\n# Get email from workspace username\n# This method is particularly useful for apps that require the email address of the viewer.\n# Indeed, in the viewer context WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app.\ndef username_to_email(username: str) -> str\n\n# Send a message to a Microsoft Teams conversation with conversation_id, where success is used to style the message\ndef send_teams_message(conversation_id: str, text: str, success: bool = True, card_block: dict = None)\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main')\n\n# Get a DuckLake client for DuckDB queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DucklakeClient instance\ndef ducklake(name: str = 'main')\n\ndef init_global_client(f)\n\ndef deprecate(in_favor_of: str)\n\n# Get the current workspace ID.\n# \n# Returns:\n# Workspace ID string\ndef get_workspace() -> str\n\ndef get_version() -> str\n\n# Run a script synchronously by hash and return its result.\n# \n# Args:\n# hash: Script hash\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# \n# Returns:\n# Script result\ndef run_script_sync(hash: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None) -> Any\n\n# Run a script synchronously by path and return its result.\n# \n# Args:\n# path: Script path\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# \n# Returns:\n# Script result\ndef run_script_by_path_sync(path: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None) -> Any\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef get_state_path() -> str\n\n# Parse resource syntax from string.\ndef parse_resource_syntax(s: str) -> Optional[str]\n\n# Parse S3 object from string or S3Object format.\ndef parse_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Parse variable syntax from string.\ndef parse_variable_syntax(s: str) -> Optional[str]\n\n# Append a text to the result stream.\n# \n# Args:\n# text: text to append to the result stream\ndef append_to_result_stream(text: str) -> None\n\n# Stream to the result stream.\n# \n# Args:\n# stream: stream to stream to the result stream\ndef stream_result(stream) -> None\n\n# Execute a SQL query against the DataTable.\n# \n# Args:\n# sql: SQL query string with $1, $2, etc. placeholders\n# *args: Positional arguments to bind to query placeholders\n# \n# Returns:\n# SqlQuery instance for fetching results\ndef query(sql: str, *args) -> SqlQuery\n\n# Execute query and fetch results.\n# \n# Args:\n# result_collection: Optional result collection mode\n# \n# Returns:\n# Query results\ndef fetch(result_collection: str | None = None)\n\n# Execute query and fetch first row of results.\n# \n# Returns:\n# First row of query results\ndef fetch_one()\n\n# Execute query and fetch first row of results. Return result as a scalar value.\n# \n# Returns:\n# First row of query result as a scalar value\ndef fetch_one_scalar()\n\n# Execute query and don't return any results.\n# \ndef execute()\n\n# DuckDB executor requires explicit argument types at declaration\n# These types exist in both DuckDB and Postgres\n# Check that the types exist if you plan to extend this function for other SQL engines.\ndef infer_sql_type(value) -> str\n\ndef parse_sql_client_name(name: str) -> tuple[str, Optional[str]]\n\n# Decorator that marks a function as a workflow task.\n# \n# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2\n# (async, checkpoint/replay) modes:\n# \n# - **v2 (inside @workflow)**: dispatches as a checkpoint step.\n# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.\n# - **Standalone**: executes the function body directly.\n# \n# Usage::\n# \n# @task\n# async def extract_data(url: str): ...\n# \n# @task(path=\"f/external_script\", timeout=600, tag=\"gpu\")\n# async def run_external(x: int): ...\ndef task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Create a task that dispatches to a separate Windmill script.\n# \n# Usage::\n# \n# extract = task_script(\"f/data/extract\", timeout=600)\n# \n# @workflow\n# async def main():\n# data = await extract(url=\"https://...\")\ndef task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Create a task that dispatches to a separate Windmill flow.\n# \n# Usage::\n# \n# pipeline = task_flow(\"f/etl/pipeline\", priority=10)\n# \n# @workflow\n# async def main():\n# result = await pipeline(input=data)\ndef task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Decorator marking an async function as a workflow-as-code entry point.\n# \n# The function must be **deterministic**: given the same inputs it must call\n# tasks in the same order on every replay. Branching on task results is fine\n# (results are replayed from checkpoint), but branching on external state\n# (current time, random values, external API calls) must use ``step()`` to\n# checkpoint the value so replays see the same result.\ndef workflow(func)\n\n# Execute ``fn`` inline and checkpoint the result.\n# \n# On replay the cached value is returned without re-executing ``fn``.\n# Use for lightweight deterministic operations (timestamps, random IDs,\n# config reads) that should not incur the overhead of a child job.\nasync def step(name: str, fn)\n\n# Server-side sleep \u2014 suspend the workflow for the given duration without holding a worker.\n# \n# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.\n# Outside a workflow, falls back to ``asyncio.sleep``.\nasync def sleep(seconds: int)\n\n# Suspend the workflow and wait for an external approval.\n# \n# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain\n# resume/cancel/approval URLs before calling this function.\n# \n# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.\n# \n# Example::\n# \n# urls = await step(\"urls\", lambda: get_resume_urls())\n# await step(\"notify\", lambda: send_email(urls[\"approvalPage\"]))\n# result = await wait_for_approval(timeout=3600)\nasync def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict\n\n# Process items in parallel with optional concurrency control.\n# \n# Each item is processed by calling ``fn(item)``, which should be a @task.\n# Items are dispatched in batches of ``concurrency`` (default: all at once).\n# \n# Example::\n# \n# @task\n# async def process(item: str):\n# ...\n# \n# results = await parallel(items, process, concurrency=5)\nasync def parallel(items, fn, concurrency: Optional[int] = None)\n\n# Commit Kafka offsets for a trigger with auto_commit disabled.\n# \n# Args:\n# trigger_path: Path to the Kafka trigger (from event['wm_trigger']['trigger_path'])\n# topic: Kafka topic name (from event['topic'])\n# partition: Partition number (from event['partition'])\n# offset: Message offset to commit (from event['offset'])\ndef commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None\n\n"; -export declare const OPENFLOW_SCHEMA = "## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"The flow will be run with the permissions of the user with this email.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"number\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"number\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"number\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"JavaScript expression to return early from the flow\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff (delay doubles each time)\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Initial delay in seconds\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note (e.g., \\\"yellow\\\", \\\"#ffff00\\\")\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"description\":\"Custom error message shown when stopping\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_use\":{\"type\":\"boolean\",\"description\":\"If true, this step's result is deleted after use to save memory\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryAuto\":{\"type\":\"object\",\"description\":\"Automatic context management\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"description\":\"Explicit message history\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Conversation memory configuration\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration. Use stop_after_if to control when the loop ends\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this tool does (shown to the AI)\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax.\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_images\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of image references for vision-capable models.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"}},\"required\":[\"provider\",\"user_message\",\"output_type\"]},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"tools\",\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}"; -export declare const CLI_COMMANDS = "# Windmill CLI Commands\n\nThe Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n## Global Options\n\n- `--workspace ` - Specify the target workspace. This overrides the default workspace.\n- `--debug --verbose` - Show debug/verbose logs\n- `--show-diffs` - Show diff informations when syncing (may show sensitive informations)\n- `--token ` - Specify an API token. This will override any stored token.\n- `--base-url ` - Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.\n- `--config-dir ` - Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.\n\n## Commands\n\n### app\n\napp related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `app list` - list all apps\n - `--json` - Output as JSON (for piping to jq)\n- `app get ` - get an app's details\n - `--json` - Output as JSON (for piping to jq)\n- `app push ` - push a local app \n- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement\n - `--port ` - Port to run the dev server on (will find next available port if occupied)\n - `--host ` - Host to bind the dev server to\n - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)\n - `--no-open` - Don't automatically open the browser\n- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability\n - `--fix` - Attempt to fix common issues (not implemented yet)\n- `app new` - create a new raw app from a template\n- `app generate-agents [app_folder:string]` - regenerate AGENTS.md and DATATABLES.md from remote workspace\n- `app generate-locks [app_folder:string]` - re-generate the lockfiles for app runnables inline scripts that have changed\n - `--yes` - Skip confirmation prompt\n - `--dry-run` - Perform a dry run without making changes\n - `--default-ts ` - Default TypeScript runtime (bun or deno)\n\n### dependencies\n\nworkspace dependencies related commands\n\n**Alias:** `deps`\n\n**Subcommands:**\n\n- `dependencies push ` - Push workspace dependencies from a local file\n\n### dev\n\nLaunch a dev server that will spawn a webserver with HMR\n\n**Options:**\n- `--includes ` - Filter paths givena glob pattern or path\n\n### docs\n\nSearch Windmill documentation. Requires Enterprise Edition.\n\n**Arguments:** ``\n\n**Options:**\n- `--json` - Output results as JSON.\n\n### flow\n\nflow related commands\n\n**Options:**\n- `--show-archived` - Enable archived flows in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `flow list` - list all flows\n - `--show-archived` - Enable archived flows in output\n - `--json` - Output as JSON (for piping to jq)\n- `flow get ` - get a flow's details\n - `--json` - Output as JSON (for piping to jq)\n- `flow push ` - push a local flow spec. This overrides any remote versions.\n- `flow run ` - run a flow by path.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.\n- `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `flow generate-locks [flow:file]` - re-generate the lock files of all inline scripts of all updated flows\n - `--yes` - Skip confirmation prompt\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n- `flow new ` - create a new empty flow\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow bootstrap ` - create a new empty flow (alias for new\n - `--summary ` - flow summary\n - `--description ` - flow description\n\n### folder\n\nfolder related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `folder list` - list all folders\n - `--json` - Output as JSON (for piping to jq)\n- `folder get ` - get a folder's details\n - `--json` - Output as JSON (for piping to jq)\n- `folder new ` - create a new folder locally\n - `--summary ` - folder summary\n- `folder push ` - push a local folder to the remote by name. This overrides any remote versions.\n- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one\n - `-y, --yes` - skip confirmation prompt\n\n### gitsync-settings\n\nManage git-sync settings between local wmill.yaml and Windmill backend\n\n**Subcommands:**\n\n- `gitsync-settings pull` - Pull git-sync settings from Windmill backend to local wmill.yaml\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--default` - Write settings to top-level defaults instead of overrides\n - `--replace` - Replace existing settings (non-interactive mode)\n - `--override` - Add branch-specific override (non-interactive mode)\n - `--diff` - Show differences without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n- `gitsync-settings push` - Push git-sync settings from local wmill.yaml to Windmill backend\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--diff` - Show what would be pushed without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n\n### hub\n\nHub related commands. EXPERIMENTAL. INTERNAL USE ONLY.\n\n**Subcommands:**\n\n- `hub pull` - pull any supported definitions. EXPERIMENTAL.\n\n### init\n\nBootstrap a windmill project with a wmill.yaml file\n\n**Options:**\n- `--use-default` - Use default settings without checking backend\n- `--use-backend` - Use backend git-sync settings if available\n- `--repository ` - Specify repository path (e.g., u/user/repo) when using backend settings\n- `--bind-profile` - Automatically bind active workspace profile to current Git branch\n- `--no-bind-profile` - Skip workspace profile binding prompt\n\n### instance\n\nsync local with a remote instance or the opposite (push or pull)\n\n**Subcommands:**\n\n- `instance add [instance_name:string] [remote:string] [token:string]` - Add a new instance\n- `instance remove ` - Remove an instance\n- `instance switch ` - Switch the current instance\n- `instance pull` - Pull instance settings, users, configs, instance groups and overwrite local\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pulling users\n - `--skip-settings` - Skip pulling settings\n - `--skip-configs` - Skip pulling configs (worker groups and SMTP)\n - `--skip-groups` - Skip pulling instance groups\n - `--include-workspaces` - Also pull workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to pull from, override the active instance\n - `--prefix ` - Prefix of the local workspaces to pull, used to create the folders when using --include-workspaces\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance push` - Push instance settings, users, configs, group and overwrite remote\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pushing users\n - `--skip-settings` - Skip pushing settings\n - `--skip-configs` - Skip pushing configs (worker groups and SMTP)\n - `--skip-groups` - Skip pushing instance groups\n - `--include-workspaces` - Also push workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to push to, override the active instance\n - `--prefix ` - Prefix of the local workspaces folders to push\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance whoami` - Display information about the currently logged-in user\n- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML\n - `-o, --output-file ` - Write YAML to a file instead of stdout\n - `--instance ` - Name of the instance, override the active instance\n\n### jobs\n\nPull completed and queued jobs from workspace\n\n**Arguments:** `[workspace:string]`\n\n**Options:**\n- `-c, --completed-output ` - Completed jobs output file (default: completed_jobs.json)\n- `-q, --queued-output ` - Queued jobs output file (default: queued_jobs.json)\n- `--skip-worker-check` - Skip checking for active workers before export\n\n**Subcommands:**\n\n- `jobs pull`\n- `jobs push`\n\n### lint\n\nValidate Windmill flow, schedule, and trigger YAML files in a directory\n\n**Arguments:** `[directory:string]`\n\n**Options:**\n- `--json` - Output results in JSON format\n- `--fail-on-warn` - Exit with code 1 when warnings are emitted\n- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n\n### queues\n\nList all queues with their metrics\n\n**Arguments:** `[workspace:string] the optional workspace to filter by (default to all workspaces)`\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### resource\n\nresource related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource list` - list all resources\n - `--json` - Output as JSON (for piping to jq)\n- `resource get ` - get a resource's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource new ` - create a new resource locally\n- `resource push ` - push a local resource spec. This overrides any remote versions.\n\n### resource-type\n\nresource type related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource-type list` - list all resource types\n - `--schema` - Show schema in the output\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type get ` - get a resource type's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type new ` - create a new resource type locally\n- `resource-type push ` - push a local resource spec. This overrides any remote versions.\n- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types\n\n### schedule\n\nschedule related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `schedule list` - list all schedules\n - `--json` - Output as JSON (for piping to jq)\n- `schedule get ` - get a schedule's details\n - `--json` - Output as JSON (for piping to jq)\n- `schedule new ` - create a new schedule locally\n- `schedule push ` - push a local schedule spec. This overrides any remote versions.\n\n### script\n\nscript related commands\n\n**Options:**\n- `--show-archived` - Enable archived scripts in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `script list` - list all scripts\n - `--show-archived` - Enable archived scripts in output\n - `--json` - Output as JSON (for piping to jq)\n- `script push ` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh\n- `script get ` - get a script's details\n - `--json` - Output as JSON (for piping to jq)\n- `script show ` - show a script's content (alias for get\n- `script run ` - run a script by path\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other than the final output. Useful for scripting.\n- `script new ` - create a new script\n - `--summary ` - script summary\n - `--description ` - script description\n- `script bootstrap ` - create a new script (alias for new\n - `--summary ` - script summary\n - `--description ` - script description\n- `script generate-metadata [script:file]` - re-generate the metadata file updating the lock and the script schema (for flows, use `wmill flow generate-locks`\n - `--yes` - Skip confirmation prompt\n - `--dry-run` - Perform a dry run without making changes\n - `--lock-only` - re-generate only the lock\n - `--schema-only` - re-generate only script schema\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n\n### sync\n\nsync local with a remote workspaces or the opposite (push or pull)\n\n**Subcommands:**\n\n- `sync pull` - Pull any remote changes and apply them locally.\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Show changes that would be pulled without actually pushing\n - `--plain-secrets` - Pull secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. Overrides wmill.yaml excludes\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n - `--branch ` - Override the current git branch (works even outside a git repository)\n- `sync push` - Push any local changes and apply them remotely.\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Show changes that would be pushed without actually pushing\n - `--plain-secrets` - Push secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push\n - `--parallel ` - Number of changes to process in parallel\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--branch ` - Override the current git branch (works even outside a git repository)\n - `--lint` - Run lint validation before pushing\n - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n\n### trigger\n\ntrigger related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `trigger list` - list all triggers\n - `--json` - Output as JSON (for piping to jq)\n- `trigger get ` - get a trigger's details\n - `--json` - Output as JSON (for piping to jq)\n - `--kind ` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email). Recommended for faster lookup\n- `trigger new ` - create a new trigger locally\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)\n- `trigger push ` - push a local trigger spec. This overrides any remote versions.\n\n### user\n\nuser related commands\n\n**Subcommands:**\n\n- `user add [password:string]` - Create a user\n - `--superadmin` - Specify to make the new user superadmin.\n - `--company ` - Specify to set the company of the new user.\n - `--name ` - Specify to set the name of the new user.\n- `user remove ` - Delete a user\n- `user create-token`\n - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n\n### variable\n\nvariable related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `variable list` - list all variables\n - `--json` - Output as JSON (for piping to jq)\n- `variable get ` - get a variable's details\n - `--json` - Output as JSON (for piping to jq)\n- `variable new ` - create a new variable locally\n- `variable push ` - Push a local variable spec. This overrides any remote versions.\n - `--plain-secrets` - Push secrets as plain text\n- `variable add ` - Create a new variable on the remote. This will update the variable if it already exists.\n - `--plain-secrets` - Push secrets as plain text\n - `--public` - Legacy option, use --plain-secrets instead\n\n### version\n\nShow version information\n\n### worker-groups\n\ndisplay worker groups, pull and push worker groups configs\n\n**Subcommands:**\n\n- `worker-groups pull` - Pull worker groups (similar to `wmill instance pull --skip-users --skip-settings --skip-groups`)\n - `--instance` - Name of the instance to push to, override the active instance\n - `--base-url` - Base url to be passed to the instance settings instead of the local one\n - `--yes` - Pull without needing confirmation\n- `worker-groups push` - Push instance settings, users, configs, group and overwrite remote\n - `--instance [instance]` - Name of the instance to push to, override the active instance\n - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n - `--yes` - Push without needing confirmation\n\n### workers\n\nList all workers grouped by worker groups\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### workspace\n\nworkspace related commands\n\n**Alias:** `profile`\n\n**Subcommands:**\n\n- `workspace switch ` - Switch to another workspace\n- `workspace add [workspace_name:string] [workspace_id:string] [remote:string]` - Add a workspace\n - `-c --create` - Create the workspace if it does not exist\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--create-username ` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.\n- `workspace remove ` - Remove a workspace\n- `workspace whoami` - Show the currently active user\n- `workspace list` - List local workspace profiles\n- `workspace list-remote` - List workspaces on the remote server that you have access to\n- `workspace bind` - Bind the current Git branch to the active workspace\n - `--branch ` - Specify branch (defaults to current)\n- `workspace unbind` - Remove workspace binding from the current Git branch\n - `--branch ` - Specify branch (defaults to current)\n- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n- `workspace delete-fork ` - Delete a forked workspace and git branch\n - `-y --yes` - Skip confirmation prompt\n\n"; +export declare const FLOW_BASE = "# Windmill Flow Building Guide\n\n## CLI Commands\n\nCreate a folder ending with `__flow` and add a `flow.yaml` file with the flow definition.\nFor rawscript modules, use `!inline path/to/script.ts` for the content key. Inline script files should NOT include `.inline_script.` in their names (e.g. use `a.ts`, not `a.inline_script.ts`).\nAfter writing, tell the user they can run:\n- `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`)\n- `wmill sync push` - Deploy to Windmill\n\nDo NOT run these commands yourself. Instead, inform the user that they should run them.\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step\n- `results.step_id.property` - Access specific property from previous step output\n- `flow_input.iter.value` - Current item when inside a for-loop\n- `flow_input.iter.index` - Current index when inside a for-loop\n\n## Input Transforms\n\nEvery rawscript module needs `input_transforms` to map function parameters to values:\n\nStatic transform (fixed value):\n{\"param_name\": {\"type\": \"static\", \"value\": \"fixed_string\"}}\n\nJavaScript transform (dynamic expression):\n{\"param_name\": {\"type\": \"javascript\", \"expr\": \"results.previous_step.data\"}}\n\n## Resource References\n\n- For flow inputs: Use type `\"object\"` with format `\"resource-{type}\"` (e.g., `\"resource-postgresql\"`)\n- For step inputs: Use static value `\"$res:path/to/resource\"`\n\n## Failure Handler\n\nExecutes when any step fails. Has access to error details:\n\n- `error.message` - Error message\n- `error.step_id` - ID of failed step\n- `error.name` - Error name\n- `error.stack` - Stack trace\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\nTo accept an S3 object as flow input:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"object\",\n \"format\": \"resource-s3_object\",\n \"description\": \"File to process\"\n }\n }\n}\n```\n\n## Using Resources in Flows\n\nOn Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.\n\n### As Flow Input\n\nIn the flow schema, set the property type to `\"object\"` with format `\"resource-{type}\"`:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"database\": {\n \"type\": \"object\",\n \"format\": \"resource-postgresql\",\n \"description\": \"Database connection\"\n }\n }\n}\n```\n\n### As Step Input (Static Reference)\n\nReference a specific resource using `$res:` prefix:\n\n```json\n{\n \"database\": {\n \"type\": \"static\",\n \"value\": \"$res:f/folder/my_database\"\n }\n}\n```\n"; +export declare const SDK_TYPESCRIPT = "# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * @deprecated Use runScriptByPath or runScriptByHash instead\n */\nasync runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead\n */\nasync runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @deprecated use setState instead\n */\nasync setInternalState(state: any): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the internal state\n * @deprecated use getState instead\n */\nasync getInternalState(): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[]): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * @deprecated use getResumeUrls instead\n */\ngetResumeEndpoints(approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Get email from workspace username\n * This method is particularly useful for apps that require the email address of the viewer.\n * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app.\n * @param username\n * @returns email address\n */\nasync usernameToEmail(username: string): Promise\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (s3://storage/key) or record\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\nasync step(name: string, fn: () => T | Promise): Promise\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage\n * URLs before calling this function.\n * \n * @example\n * const urls = await step(\"urls\", () => getResumeUrls());\n * await step(\"notify\", () => sendEmail(urls.approvalPage));\n * const { value, approver } = await waitForApproval({ timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n */\ndatatable(name: string = \"main\"): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n"; +export declare const SDK_PYTHON = "# Python SDK (wmill)\n\nImport: import wmill\n\ndef get_mocked_api() -> Optional[dict]\n\n# Get the HTTP client instance.\n# \n# Returns:\n# Configured httpx.Client for API requests\ndef get_client() -> httpx.Client\n\n# Make an HTTP GET request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.get\n# \n# Returns:\n# HTTP response object\ndef get(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Make an HTTP POST request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.post\n# \n# Returns:\n# HTTP response object\ndef post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Create a new authentication token.\n# \n# Args:\n# duration: Token validity duration (default: 1 day)\n# \n# Returns:\n# New authentication token string\ndef create_token(duration = dt.timedelta(days=1)) -> str\n\n# Create a script job and return its job id.\n# \n# .. deprecated:: Use run_script_by_path_async or run_script_by_hash_async instead.\ndef run_script_async(path: str = None, hash_: str = None, args: dict = None, scheduled_in_secs: int = None) -> str\n\n# Create a script job by path and return its job id.\ndef run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None) -> str\n\n# Create a script job by hash and return its job id.\ndef run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None) -> str\n\n# Create a flow job and return its job id.\ndef run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True) -> str\n\n# Run script synchronously and return its result.\n# \n# .. deprecated:: Use run_script_by_path or run_script_by_hash instead.\ndef run_script(path: str = None, hash_: str = None, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False) -> Any\n\n# Run script by path synchronously and return its result.\ndef run_script_by_path(path: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False) -> Any\n\n# Run script by hash synchronously and return its result.\ndef run_script_by_hash(hash_: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False) -> Any\n\n# Run a script on the current worker without creating a job\ndef run_inline_script_preview(content: str, language: str, args: dict = None) -> Any\n\n# Wait for a job to complete and return its result.\n# \n# Args:\n# job_id: ID of the job to wait for\n# timeout: Maximum time to wait (seconds or timedelta)\n# verbose: Enable verbose logging\n# cleanup: Register cleanup handler to cancel job on exit\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result when completed\n# \n# Raises:\n# TimeoutError: If timeout is reached\n# Exception: If job fails\ndef wait_job(job_id, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False)\n\n# Cancel a specific job by ID.\n# \n# Args:\n# job_id: UUID of the job to cancel\n# reason: Optional reason for cancellation\n# \n# Returns:\n# Response message from the cancel endpoint\ndef cancel_job(job_id: str, reason: str = None) -> str\n\n# Cancel currently running executions of the same script.\ndef cancel_running() -> dict\n\n# Get job details by ID.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job details dictionary\ndef get_job(job_id: str) -> dict\n\n# Get the root job ID for a flow hierarchy.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Root job ID\ndef get_root_job_id(job_id: str | None = None) -> dict\n\n# Get an OIDC JWT token for authentication to external services.\n# \n# Args:\n# audience: Token audience (e.g., \"vault\", \"aws\")\n# expires_in: Optional expiration time in seconds\n# \n# Returns:\n# JWT token string\ndef get_id_token(audience: str, expires_in: int | None = None) -> str\n\n# Get the status of a job.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job status: \"RUNNING\", \"WAITING\", or \"COMPLETED\"\ndef get_job_status(job_id: str) -> JobStatus\n\n# Get the result of a completed job.\n# \n# Args:\n# job_id: UUID of the completed job\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result\ndef get_result(job_id: str, assert_result_is_not_none: bool = True) -> Any\n\n# Get a variable value by path.\n# \n# Args:\n# path: Variable path in Windmill\n# \n# Returns:\n# Variable value as string\ndef get_variable(path: str) -> str\n\n# Set a variable value by path, creating it if it doesn't exist.\n# \n# Args:\n# path: Variable path in Windmill\n# value: Variable value to set\n# is_secret: Whether the variable should be secret (default: False)\ndef set_variable(path: str, value: str, is_secret: bool = False) -> None\n\n# Get a resource value by path.\n# \n# Args:\n# path: Resource path in Windmill\n# none_if_undefined: Return None instead of raising if not found\n# interpolated: if variables and resources are fully unrolled\n# \n# Returns:\n# Resource value dictionary or None\ndef get_resource(path: str, none_if_undefined: bool = False, interpolated: bool = True) -> dict | None\n\n# Set a resource value by path, creating it if it doesn't exist.\n# \n# Args:\n# value: Resource value to set\n# path: Resource path in Windmill\n# resource_type: Resource type for creation\ndef set_resource(value: Any, path: str, resource_type: str)\n\n# List resources from Windmill workspace.\n# \n# Args:\n# resource_type: Optional resource type to filter by (e.g., \"postgresql\", \"mysql\", \"s3\")\n# page: Optional page number for pagination\n# per_page: Optional number of results per page\n# \n# Returns:\n# List of resource dictionaries\ndef list_resources(resource_type: str = None, page: int = None, per_page: int = None) -> list[dict]\n\n# Set the workflow state.\n# \n# Args:\n# value: State value to set\n# path: Optional state resource path override.\ndef set_state(value: Any, path: str | None = None) -> None\n\n# Get the workflow state.\n# \n# Args:\n# path: Optional state resource path override.\n# \n# Returns:\n# State value or None if not set\ndef get_state(path: str | None = None) -> Any\n\n# Set job progress percentage (0-99).\n# \n# Args:\n# value: Progress percentage\n# job_id: Job ID (defaults to current WM_JOB_ID)\ndef set_progress(value: int, job_id: Optional[str] = None)\n\n# Get job progress percentage.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Progress value (0-100) or None if not set\ndef get_progress(job_id: Optional[str] = None) -> Any\n\n# Set the user state of a flow at a given key\ndef set_flow_user_state(key: str, value: Any) -> None\n\n# Get the user state of a flow at a given key\ndef get_flow_user_state(key: str) -> Any\n\n# Get the Windmill server version.\n# \n# Returns:\n# Version string\ndef version()\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Load a file from the workspace s3 bucket and returns its content as bytes.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# my_obj_content = client.load_s3_file(s3_obj)\n# file_content = my_obj_content.decode(\"utf-8\")\n# '''\ndef load_s3_file(s3object: S3Object | str, s3_resource_path: str | None) -> bytes\n\n# Load a file from the workspace s3 bucket and returns the bytes stream.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# with wmill.load_s3_file_reader(s3object, s3_resource_path) as file_reader:\n# print(file_reader.read())\n# '''\ndef load_s3_file_reader(s3object: S3Object | str, s3_resource_path: str | None) -> BufferedReader\n\n# Write a file to the workspace S3 bucket\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# \n# # for an in memory bytes array:\n# file_content = b'Hello Windmill!'\n# client.write_s3_file(s3_obj, file_content)\n# \n# # for a file:\n# with open(\"my_file.txt\", \"rb\") as my_file:\n# client.write_s3_file(s3_obj, my_file)\n# '''\ndef write_s3_file(s3object: S3Object | str | None, file_content: BufferedReader | bytes, s3_resource_path: str | None, content_type: str | None = None, content_disposition: str | None = None) -> S3Object\n\n# Permanently delete a file from the workspace S3 bucket.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# client.delete_s3_object(s3_obj)\n# '''\ndef delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = None) -> None\n\n# Sign S3 objects for use by anonymous users in public apps.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# \n# Returns:\n# List of signed S3 objects\ndef sign_s3_objects(s3_objects: list[S3Object | str]) -> list[S3Object]\n\n# Sign a single S3 object for use by anonymous users in public apps.\n# \n# Args:\n# s3_object: S3 object to sign\n# \n# Returns:\n# Signed S3 object\ndef sign_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Generate presigned public URLs for an array of S3 objects.\n# If an S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# base_url: Optional base URL for the presigned URLs (defaults to WM_BASE_URL)\n# \n# Returns:\n# List of signed public URLs\n# \n# Example:\n# >>> s3_objs = [S3Object(s3=\"/path/to/file1.txt\"), S3Object(s3=\"/path/to/file2.txt\")]\n# >>> urls = client.get_presigned_s3_public_urls(s3_objs)\ndef get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None) -> list[str]\n\n# Generate a presigned public URL for an S3 object.\n# If the S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_object: S3 object to sign\n# base_url: Optional base URL for the presigned URL (defaults to WM_BASE_URL)\n# \n# Returns:\n# Signed public URL\n# \n# Example:\n# >>> s3_obj = S3Object(s3=\"/path/to/file.txt\")\n# >>> url = client.get_presigned_s3_public_url(s3_obj)\ndef get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None) -> str\n\n# Get the current user information.\n# \n# Returns:\n# User details dictionary\ndef whoami() -> dict\n\n# Get the current user information (alias for whoami).\n# \n# Returns:\n# User details dictionary\ndef user() -> dict\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef state_path() -> str\n\n# Get the workflow state.\n# \n# Returns:\n# State value or None if not set\ndef state() -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state_pickle(value: Any, path: str = 'state.pickle') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state_pickle(path: str = 'state.pickle') -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state(value: Any, path: str = 'state.json') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state(path: str = 'state.json') -> None\n\n# Get URLs needed for resuming a flow after suspension.\n# \n# Args:\n# approver: Optional approver name\n# flow_level: If True, generate resume URLs for the parent flow instead of the\n# specific step. This allows pre-approvals that can be consumed by any later\n# suspend step in the same flow.\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_resume_urls(approver: str = None, flow_level: bool = None) -> dict\n\n# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n# \n# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the \"Advanced -> Suspend -> Form\" functionality.\n# Learn more at: https://www.windmill.dev/docs/flows/flow_approval#form\n# \n# :param slack_resource_path: The path to the Slack resource in Windmill.\n# :type slack_resource_path: str\n# :param channel_id: The Slack channel ID where the approval request will be sent.\n# :type channel_id: str\n# :param message: Optional custom message to include in the Slack approval request.\n# :type message: str, optional\n# :param approver: Optional user ID or name of the approver for the request.\n# :type approver: str, optional\n# :param default_args_json: Optional dictionary defining or overriding the default arguments for form fields.\n# :type default_args_json: dict, optional\n# :param dynamic_enums_json: Optional dictionary overriding the enum default values of enum form fields.\n# :type dynamic_enums_json: dict, optional\n# \n# :raises Exception: If the function is not called within a flow or flow preview.\n# :raises Exception: If the required flow job or flow step environment variables are not set.\n# \n# :return: None\n# \n# **Usage Example:**\n# >>> client.request_interactive_slack_approval(\n# ... slack_resource_path=\"/u/alex/my_slack_resource\",\n# ... channel_id=\"admins-slack-channel\",\n# ... message=\"Please approve this request\",\n# ... approver=\"approver123\",\n# ... default_args_json={\"key1\": \"value1\", \"key2\": 42},\n# ... dynamic_enums_json={\"foo\": [\"choice1\", \"choice2\"], \"bar\": [\"optionA\", \"optionB\"]},\n# ... )\n# \n# **Notes:**\n# - This function must be executed within a Windmill flow or flow preview.\n# - The function checks for required environment variables (`WM_FLOW_JOB_ID`, `WM_FLOW_STEP_ID`) to ensure it is run in the appropriate context.\ndef request_interactive_slack_approval(slack_resource_path: str, channel_id: str, message: str = None, approver: str = None, default_args_json: dict = None, dynamic_enums_json: dict = None) -> None\n\n# Get email from workspace username\n# This method is particularly useful for apps that require the email address of the viewer.\n# Indeed, in the viewer context WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app.\ndef username_to_email(username: str) -> str\n\n# Send a message to a Microsoft Teams conversation with conversation_id, where success is used to style the message\ndef send_teams_message(conversation_id: str, text: str, success: bool = True, card_block: dict = None)\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main')\n\n# Get a DuckLake client for DuckDB queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DucklakeClient instance\ndef ducklake(name: str = 'main')\n\ndef init_global_client(f)\n\ndef deprecate(in_favor_of: str)\n\n# Get the current workspace ID.\n# \n# Returns:\n# Workspace ID string\ndef get_workspace() -> str\n\ndef get_version() -> str\n\n# Run a script synchronously by hash and return its result.\n# \n# Args:\n# hash: Script hash\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# \n# Returns:\n# Script result\ndef run_script_sync(hash: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None) -> Any\n\n# Run a script synchronously by path and return its result.\n# \n# Args:\n# path: Script path\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# \n# Returns:\n# Script result\ndef run_script_by_path_sync(path: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None) -> Any\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef get_state_path() -> str\n\n# Parse resource syntax from string.\ndef parse_resource_syntax(s: str) -> Optional[str]\n\n# Parse S3 object from string or S3Object format.\ndef parse_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Parse variable syntax from string.\ndef parse_variable_syntax(s: str) -> Optional[str]\n\n# Append a text to the result stream.\n# \n# Args:\n# text: text to append to the result stream\ndef append_to_result_stream(text: str) -> None\n\n# Stream to the result stream.\n# \n# Args:\n# stream: stream to stream to the result stream\ndef stream_result(stream) -> None\n\n# Execute a SQL query against the DataTable.\n# \n# Args:\n# sql: SQL query string with $1, $2, etc. placeholders\n# *args: Positional arguments to bind to query placeholders\n# \n# Returns:\n# SqlQuery instance for fetching results\ndef query(sql: str, *args) -> SqlQuery\n\n# Execute query and fetch results.\n# \n# Args:\n# result_collection: Optional result collection mode\n# \n# Returns:\n# Query results\ndef fetch(result_collection: str | None = None)\n\n# Execute query and fetch first row of results.\n# \n# Returns:\n# First row of query results\ndef fetch_one()\n\n# Execute query and fetch first row of results. Return result as a scalar value.\n# \n# Returns:\n# First row of query result as a scalar value\ndef fetch_one_scalar()\n\n# Execute query and don't return any results.\n# \ndef execute()\n\n# DuckDB executor requires explicit argument types at declaration\n# These types exist in both DuckDB and Postgres\n# Check that the types exist if you plan to extend this function for other SQL engines.\ndef infer_sql_type(value) -> str\n\ndef parse_sql_client_name(name: str) -> tuple[str, Optional[str]]\n\n# Decorator that marks a function as a workflow task.\n# \n# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2\n# (async, checkpoint/replay) modes:\n# \n# - **v2 (inside @workflow)**: dispatches as a checkpoint step.\n# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.\n# - **Standalone**: executes the function body directly.\n# \n# Usage::\n# \n# @task\n# async def extract_data(url: str): ...\n# \n# @task(path=\"f/external_script\", timeout=600, tag=\"gpu\")\n# async def run_external(x: int): ...\ndef task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Create a task that dispatches to a separate Windmill script.\n# \n# Usage::\n# \n# extract = task_script(\"f/data/extract\", timeout=600)\n# \n# @workflow\n# async def main():\n# data = await extract(url=\"https://...\")\ndef task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Create a task that dispatches to a separate Windmill flow.\n# \n# Usage::\n# \n# pipeline = task_flow(\"f/etl/pipeline\", priority=10)\n# \n# @workflow\n# async def main():\n# result = await pipeline(input=data)\ndef task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Decorator marking an async function as a workflow-as-code entry point.\n# \n# The function must be **deterministic**: given the same inputs it must call\n# tasks in the same order on every replay. Branching on task results is fine\n# (results are replayed from checkpoint), but branching on external state\n# (current time, random values, external API calls) must use ``step()`` to\n# checkpoint the value so replays see the same result.\ndef workflow(func)\n\n# Execute ``fn`` inline and checkpoint the result.\n# \n# On replay the cached value is returned without re-executing ``fn``.\n# Use for lightweight deterministic operations (timestamps, random IDs,\n# config reads) that should not incur the overhead of a child job.\nasync def step(name: str, fn)\n\n# Server-side sleep \u2014 suspend the workflow for the given duration without holding a worker.\n# \n# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.\n# Outside a workflow, falls back to ``asyncio.sleep``.\nasync def sleep(seconds: int)\n\n# Suspend the workflow and wait for an external approval.\n# \n# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain\n# resume/cancel/approval URLs before calling this function.\n# \n# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.\n# \n# Args:\n# timeout: Approval timeout in seconds (default 1800).\n# form: Optional form schema for the approval page.\n# self_approval: Whether the user who triggered the flow can approve it (default True).\n# \n# Example::\n# \n# urls = await step(\"urls\", lambda: get_resume_urls())\n# await step(\"notify\", lambda: send_email(urls[\"approvalPage\"]))\n# result = await wait_for_approval(timeout=3600)\nasync def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict\n\n# Process items in parallel with optional concurrency control.\n# \n# Each item is processed by calling ``fn(item)``, which should be a @task.\n# Items are dispatched in batches of ``concurrency`` (default: all at once).\n# \n# Example::\n# \n# @task\n# async def process(item: str):\n# ...\n# \n# results = await parallel(items, process, concurrency=5)\nasync def parallel(items, fn, concurrency: Optional[int] = None)\n\n# Commit Kafka offsets for a trigger with auto_commit disabled.\n# \n# Args:\n# trigger_path: Path to the Kafka trigger (from event['wm_trigger']['trigger_path'])\n# topic: Kafka topic name (from event['topic'])\n# partition: Partition number (from event['partition'])\n# offset: Message offset to commit (from event['offset'])\ndef commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None\n\n"; +export declare const OPENFLOW_SCHEMA = "## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"The flow will be run with the permissions of the user with this email.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"JavaScript expression to return early from the flow\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff (delay doubles each time)\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Initial delay in seconds\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note (e.g., \\\"yellow\\\", \\\"#ffff00\\\")\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_use\":{\"type\":\"boolean\",\"description\":\"If true, this step's result is deleted after use to save memory\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryAuto\":{\"type\":\"object\",\"description\":\"Automatic context management\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"description\":\"Explicit message history\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Conversation memory configuration\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration. Use stop_after_if to control when the loop ends\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this tool does (shown to the AI)\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax.\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"}},\"required\":[\"provider\",\"user_message\",\"output_type\"]},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"tools\",\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}"; +export declare const CLI_COMMANDS = "# Windmill CLI Commands\n\nThe Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n## Global Options\n\n- `--workspace ` - Specify the target workspace. This overrides the default workspace.\n- `--debug --verbose` - Show debug/verbose logs\n- `--show-diffs` - Show diff informations when syncing (may show sensitive informations)\n- `--token ` - Specify an API token. This will override any stored token.\n- `--base-url ` - Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.\n- `--config-dir ` - Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.\n\n## Commands\n\n### app\n\napp related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `app list` - list all apps\n - `--json` - Output as JSON (for piping to jq)\n- `app get ` - get an app's details\n - `--json` - Output as JSON (for piping to jq)\n- `app push ` - push a local app \n- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement\n - `--port ` - Port to run the dev server on (will find next available port if occupied)\n - `--host ` - Host to bind the dev server to\n - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)\n - `--no-open` - Don't automatically open the browser\n- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability\n - `--fix` - Attempt to fix common issues (not implemented yet)\n- `app new` - create a new raw app from a template\n- `app generate-agents [app_folder:string]` - regenerate AGENTS.md and DATATABLES.md from remote workspace\n- `app generate-locks [app_folder:string]` - re-generate the lockfiles for app runnables inline scripts that have changed\n - `--yes` - Skip confirmation prompt\n - `--dry-run` - Perform a dry run without making changes\n - `--default-ts ` - Default TypeScript runtime (bun or deno)\n\n### dependencies\n\nworkspace dependencies related commands\n\n**Alias:** `deps`\n\n**Subcommands:**\n\n- `dependencies push ` - Push workspace dependencies from a local file\n\n### dev\n\nLaunch a dev server that will spawn a webserver with HMR\n\n**Options:**\n- `--includes ` - Filter paths givena glob pattern or path\n\n### docs\n\nSearch Windmill documentation. Requires Enterprise Edition.\n\n**Arguments:** ``\n\n**Options:**\n- `--json` - Output results as JSON.\n\n### flow\n\nflow related commands\n\n**Options:**\n- `--show-archived` - Enable archived flows in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `flow list` - list all flows\n - `--show-archived` - Enable archived flows in output\n - `--json` - Output as JSON (for piping to jq)\n- `flow get ` - get a flow's details\n - `--json` - Output as JSON (for piping to jq)\n- `flow push ` - push a local flow spec. This overrides any remote versions.\n- `flow run ` - run a flow by path.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.\n- `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.\n- `flow generate-locks [flow:file]` - re-generate the lock files of all inline scripts of all updated flows\n - `--yes` - Skip confirmation prompt\n - `--dry-run` - Perform a dry run without making changes\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n- `flow new ` - create a new empty flow\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow bootstrap ` - create a new empty flow (alias for new\n - `--summary ` - flow summary\n - `--description ` - flow description\n\n### folder\n\nfolder related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `folder list` - list all folders\n - `--json` - Output as JSON (for piping to jq)\n- `folder get ` - get a folder's details\n - `--json` - Output as JSON (for piping to jq)\n- `folder new ` - create a new folder locally\n - `--summary ` - folder summary\n- `folder push ` - push a local folder to the remote by name. This overrides any remote versions.\n- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one\n - `-y, --yes` - skip confirmation prompt\n\n### generate-metadata\n\nGenerate metadata (locks, schemas) for all scripts, flows, and apps\n\n**Arguments:** `[folder:string]`\n\n**Options:**\n- `--yes` - Skip confirmation prompt\n- `--dry-run` - Show what would be updated without making changes\n- `--lock-only` - Re-generate only the lock files\n- `--schema-only` - Re-generate only script schemas (skips flows and apps)\n- `--skip-scripts` - Skip processing scripts\n- `--skip-flows` - Skip processing flows\n- `--skip-apps` - Skip processing apps\n- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument)\n- `-i --includes ` - Comma separated patterns to specify which files to include\n- `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n### gitsync-settings\n\nManage git-sync settings between local wmill.yaml and Windmill backend\n\n**Subcommands:**\n\n- `gitsync-settings pull` - Pull git-sync settings from Windmill backend to local wmill.yaml\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--default` - Write settings to top-level defaults instead of overrides\n - `--replace` - Replace existing settings (non-interactive mode)\n - `--override` - Add branch-specific override (non-interactive mode)\n - `--diff` - Show differences without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n- `gitsync-settings push` - Push git-sync settings from local wmill.yaml to Windmill backend\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--diff` - Show what would be pushed without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n\n### hub\n\nHub related commands. EXPERIMENTAL. INTERNAL USE ONLY.\n\n**Subcommands:**\n\n- `hub pull` - pull any supported definitions. EXPERIMENTAL.\n\n### init\n\nBootstrap a windmill project with a wmill.yaml file\n\n**Options:**\n- `--use-default` - Use default settings without checking backend\n- `--use-backend` - Use backend git-sync settings if available\n- `--repository ` - Specify repository path (e.g., u/user/repo) when using backend settings\n- `--bind-profile` - Automatically bind active workspace profile to current Git branch\n- `--no-bind-profile` - Skip workspace profile binding prompt\n\n### instance\n\nsync local with a remote instance or the opposite (push or pull)\n\n**Subcommands:**\n\n- `instance add [instance_name:string] [remote:string] [token:string]` - Add a new instance\n- `instance remove ` - Remove an instance\n- `instance switch ` - Switch the current instance\n- `instance pull` - Pull instance settings, users, configs, instance groups and overwrite local\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pulling users\n - `--skip-settings` - Skip pulling settings\n - `--skip-configs` - Skip pulling configs (worker groups and SMTP)\n - `--skip-groups` - Skip pulling instance groups\n - `--include-workspaces` - Also pull workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to pull from, override the active instance\n - `--prefix ` - Prefix of the local workspaces to pull, used to create the folders when using --include-workspaces\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance push` - Push instance settings, users, configs, group and overwrite remote\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pushing users\n - `--skip-settings` - Skip pushing settings\n - `--skip-configs` - Skip pushing configs (worker groups and SMTP)\n - `--skip-groups` - Skip pushing instance groups\n - `--include-workspaces` - Also push workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to push to, override the active instance\n - `--prefix ` - Prefix of the local workspaces folders to push\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance whoami` - Display information about the currently logged-in user\n- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML\n - `-o, --output-file ` - Write YAML to a file instead of stdout\n - `--instance ` - Name of the instance, override the active instance\n\n### jobs\n\nPull completed and queued jobs from workspace\n\n**Arguments:** `[workspace:string]`\n\n**Options:**\n- `-c, --completed-output ` - Completed jobs output file (default: completed_jobs.json)\n- `-q, --queued-output ` - Queued jobs output file (default: queued_jobs.json)\n- `--skip-worker-check` - Skip checking for active workers before export\n\n**Subcommands:**\n\n- `jobs pull`\n- `jobs push`\n\n### lint\n\nValidate Windmill flow, schedule, and trigger YAML files in a directory\n\n**Arguments:** `[directory:string]`\n\n**Options:**\n- `--json` - Output results in JSON format\n- `--fail-on-warn` - Exit with code 1 when warnings are emitted\n- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n\n### queues\n\nList all queues with their metrics\n\n**Arguments:** `[workspace:string] the optional workspace to filter by (default to all workspaces)`\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### resource\n\nresource related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource list` - list all resources\n - `--json` - Output as JSON (for piping to jq)\n- `resource get ` - get a resource's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource new ` - create a new resource locally\n- `resource push ` - push a local resource spec. This overrides any remote versions.\n\n### resource-type\n\nresource type related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource-type list` - list all resource types\n - `--schema` - Show schema in the output\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type get ` - get a resource type's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type new ` - create a new resource type locally\n- `resource-type push ` - push a local resource spec. This overrides any remote versions.\n- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types\n\n### schedule\n\nschedule related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `schedule list` - list all schedules\n - `--json` - Output as JSON (for piping to jq)\n- `schedule get ` - get a schedule's details\n - `--json` - Output as JSON (for piping to jq)\n- `schedule new ` - create a new schedule locally\n- `schedule push ` - push a local schedule spec. This overrides any remote versions.\n\n### script\n\nscript related commands\n\n**Options:**\n- `--show-archived` - Enable archived scripts in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `script list` - list all scripts\n - `--show-archived` - Enable archived scripts in output\n - `--json` - Output as JSON (for piping to jq)\n- `script push ` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh\n- `script get ` - get a script's details\n - `--json` - Output as JSON (for piping to jq)\n- `script show ` - show a script's content (alias for get\n- `script run ` - run a script by path\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other than the final output. Useful for scripting.\n- `script new ` - create a new script\n - `--summary ` - script summary\n - `--description ` - script description\n- `script bootstrap ` - create a new script (alias for new\n - `--summary ` - script summary\n - `--description ` - script description\n- `script generate-metadata [script:file]` - re-generate the metadata file updating the lock and the script schema (for flows, use `wmill flow generate-locks`\n - `--yes` - Skip confirmation prompt\n - `--dry-run` - Perform a dry run without making changes\n - `--lock-only` - re-generate only the lock\n - `--schema-only` - re-generate only script schema\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n\n### sync\n\nsync local with a remote workspaces or the opposite (push or pull)\n\n**Subcommands:**\n\n- `sync pull` - Pull any remote changes and apply them locally.\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Show changes that would be pulled without actually pushing\n - `--plain-secrets` - Pull secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. Overrides wmill.yaml excludes\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository)\n- `sync push` - Push any local changes and apply them remotely.\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Show changes that would be pushed without actually pushing\n - `--plain-secrets` - Push secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push\n - `--parallel ` - Number of changes to process in parallel\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository)\n - `--lint` - Run lint validation before pushing\n - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n\n### trigger\n\ntrigger related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `trigger list` - list all triggers\n - `--json` - Output as JSON (for piping to jq)\n- `trigger get ` - get a trigger's details\n - `--json` - Output as JSON (for piping to jq)\n - `--kind ` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email). Recommended for faster lookup\n- `trigger new ` - create a new trigger locally\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)\n- `trigger push ` - push a local trigger spec. This overrides any remote versions.\n\n### user\n\nuser related commands\n\n**Subcommands:**\n\n- `user add [password:string]` - Create a user\n - `--superadmin` - Specify to make the new user superadmin.\n - `--company ` - Specify to set the company of the new user.\n - `--name ` - Specify to set the name of the new user.\n- `user remove ` - Delete a user\n- `user create-token`\n - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n\n### variable\n\nvariable related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `variable list` - list all variables\n - `--json` - Output as JSON (for piping to jq)\n- `variable get ` - get a variable's details\n - `--json` - Output as JSON (for piping to jq)\n- `variable new ` - create a new variable locally\n- `variable push ` - Push a local variable spec. This overrides any remote versions.\n - `--plain-secrets` - Push secrets as plain text\n- `variable add ` - Create a new variable on the remote. This will update the variable if it already exists.\n - `--plain-secrets` - Push secrets as plain text\n - `--public` - Legacy option, use --plain-secrets instead\n\n### version\n\nShow version information\n\n### worker-groups\n\ndisplay worker groups, pull and push worker groups configs\n\n**Subcommands:**\n\n- `worker-groups pull` - Pull worker groups (similar to `wmill instance pull --skip-users --skip-settings --skip-groups`)\n - `--instance` - Name of the instance to push to, override the active instance\n - `--base-url` - Base url to be passed to the instance settings instead of the local one\n - `--yes` - Pull without needing confirmation\n- `worker-groups push` - Push instance settings, users, configs, group and overwrite remote\n - `--instance [instance]` - Name of the instance to push to, override the active instance\n - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n - `--yes` - Push without needing confirmation\n\n### workers\n\nList all workers grouped by worker groups\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### workspace\n\nworkspace related commands\n\n**Alias:** `profile`\n\n**Subcommands:**\n\n- `workspace switch ` - Switch to another workspace\n- `workspace add [workspace_name:string] [workspace_id:string] [remote:string]` - Add a workspace\n - `-c --create` - Create the workspace if it does not exist\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--create-username ` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.\n- `workspace remove ` - Remove a workspace\n- `workspace whoami` - Show the currently active user\n- `workspace list` - List local workspace profiles\n- `workspace list-remote` - List workspaces on the remote server that you have access to\n- `workspace bind` - Bind the current Git branch to the active workspace\n - `--branch, --env ` - Specify branch/environment (defaults to current)\n- `workspace unbind` - Remove workspace binding from the current Git branch\n - `--branch, --env ` - Specify branch/environment (defaults to current)\n- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n- `workspace delete-fork ` - Delete a forked workspace and git branch\n - `-y --yes` - Skip confirmation prompt\n\n"; export declare const LANG_BASH = "# Bash\n\n## Structure\n\nDo not include `#!/bin/bash`. Arguments are obtained as positional parameters:\n\n```bash\n# Get arguments\nvar1=\"$1\"\nvar2=\"$2\"\n\necho \"Processing $var1 and $var2\"\n\n# Return JSON by echoing to stdout\necho \"{\\\"result\\\": \\\"$var1\\\", \\\"count\\\": $var2}\"\n```\n\n**Important:**\n- Do not include shebang (`#!/bin/bash`)\n- Arguments are always strings\n- Access with `$1`, `$2`, etc.\n\n## Output\n\nThe script output is captured as the result. For structured data, output valid JSON:\n\n```bash\nname=\"$1\"\ncount=\"$2\"\n\n# Output JSON result\ncat << EOF\n{\n \"name\": \"$name\",\n \"count\": $count,\n \"timestamp\": \"$(date -Iseconds)\"\n}\nEOF\n```\n\n## Environment Variables\n\nEnvironment variables set in Windmill are available:\n\n```bash\n# Access environment variable\necho \"Workspace: $WM_WORKSPACE\"\necho \"Job ID: $WM_JOB_ID\"\n```\n"; export declare const LANG_BIGQUERY = "# BigQuery\n\nArguments use `@name` syntax.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @name1 (string)\n-- @name2 (int64) = 0\nSELECT * FROM users WHERE name = @name1 AND age > @name2;\n```\n"; export declare const LANG_BUN = "# TypeScript (Bun)\n\nBun runtime with full npm ecosystem and fastest execution.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n```typescript\nimport Stripe from \"stripe\";\nimport { someFunction } from \"some-package\";\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\n### S3Object Type\n\nThe S3Object type represents a file in S3 storage:\n\n```typescript\ntype S3Object = {\n s3: string; // Path within the bucket\n};\n```\n\n## TypeScript Operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n"; diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 6eb870e62b..dc47b66eca 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1523,7 +1523,7 @@ class SqlQuery: export const OPENFLOW_SCHEMA = `## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 17a94194d8..6f7dec8234 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -125,4 +125,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file From 1a73012e0737a6ebea8307013dc0f79982269d91 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:59:45 +0100 Subject: [PATCH 056/153] fix: filter null entries in FileUpload initialValue to prevent s3 access error (#8544) Co-authored-by: Claude Opus 4.5 --- .../src/lib/components/common/fileUpload/FileUpload.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/common/fileUpload/FileUpload.svelte b/frontend/src/lib/components/common/fileUpload/FileUpload.svelte index f0ce068ea7..a7ba9a1c34 100644 --- a/frontend/src/lib/components/common/fileUpload/FileUpload.svelte +++ b/frontend/src/lib/components/common/fileUpload/FileUpload.svelte @@ -92,7 +92,7 @@ let initialS3 = $derived( Array.isArray(initialValue) - ? initialValue?.map((v) => v.s3) + ? initialValue?.filter((v) => v != null).map((v) => v.s3) : initialValue?.s3 ? [initialValue?.s3] : undefined @@ -112,7 +112,7 @@ if (!$fileUploads.find((fileUpload) => fileUpload.path === s3)) { let initialFileUploads = initialValue ? Array.isArray(initialValue) - ? initialValue.map(transform) + ? initialValue.filter((v) => v != null).map(transform) : [transform(initialValue)] : [] $fileUploads = [...$fileUploads, ...initialFileUploads] From 1fa4d919b30ac9eff2d1789fba2695450ba115e7 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:40:51 +0100 Subject: [PATCH 057/153] fix: upload_s3_file not working in VS Code extension (#8547) --- .../src/lib/components/common/fileUpload/FileUpload.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/lib/components/common/fileUpload/FileUpload.svelte b/frontend/src/lib/components/common/fileUpload/FileUpload.svelte index a7ba9a1c34..dfe5c158cc 100644 --- a/frontend/src/lib/components/common/fileUpload/FileUpload.svelte +++ b/frontend/src/lib/components/common/fileUpload/FileUpload.svelte @@ -6,6 +6,7 @@ import { sendUserToast } from '$lib/toast' import { workspaceStore } from '$lib/stores' import { AppService, HelpersService } from '$lib/gen' + import { OpenAPI } from '$lib/gen/core/OpenAPI' import { writable, type Writable } from 'svelte/store' import { Ban, CheckCheck, FileWarning, Files, RefreshCcw, Trash, XIcon } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' @@ -334,6 +335,9 @@ true ) xhr?.setRequestHeader('Content-Type', 'application/octet-stream') + if (OpenAPI.TOKEN) { + xhr?.setRequestHeader('Authorization', `Bearer ${OpenAPI.TOKEN}`) + } xhr?.send(fileToUpload) })) as any From 71549c3db053bcc209c7065ac8cd42f1e8047cc3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 18:14:10 +0000 Subject: [PATCH 058/153] fix: resolve parent_hash race condition in sync push with auto_parent (#8545) * fix: resolve parent_hash race condition in sync push with auto_parent During concurrent sync push operations (parallel CLI groups or separate CI pipelines), multiple requests could read the same remote script hash and both try to create a new version with the same parent_hash, causing "the lineage must be linear" errors. Adds an opt-in `auto_parent` field to the create_script API. When set, the backend resolves the parent_hash to the current head script at that path within the transaction, atomically. This eliminates the client-side race window where the parent could change between read and write. The CLI now sends `auto_parent: true` when updating existing scripts, so sync push is resilient to concurrent deployments. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add missing auto_parent field in clone_script NewScript initializer Co-Authored-By: Claude Opus 4.5 * fix: add advisory lock to serialize concurrent auto_parent script creates Co-Authored-By: Claude Opus 4.5 * sqlx * fix: add sqlx anchor for CE-only user count query Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...2f88594825dbaa647290a58bd63df61b531a7.json | 2 +- ...96cc3ba1957042a48ac5f9629ada25b3e78ef.json | 2 +- ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...2561a7d098f2af5287e1e6c339e15080378be.json | 23 +++++++++++++++++++ ...8251dbb3c6d4c095efa015823f0324ab27d7f.json | 2 +- ...8e702fc1577d3fa4ff1ff2f1e089971ff5e32.json | 2 +- ...2d14755474cba82b3b388a47585a8bb325b1a.json | 17 -------------- backend/windmill-api-scripts/src/scripts.rs | 22 +++++++++++++++++- .../windmill-api-workspaces/src/workspaces.rs | 12 ++++++++++ backend/windmill-common/src/scripts.rs | 1 + backend/windmill-types/src/scripts.rs | 2 ++ cli/src/commands/script/script.ts | 1 + 12 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 backend/.sqlx/query-6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be.json delete mode 100644 backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json diff --git a/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json b/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json index e2c5050f1d..9768a13f3d 100644 --- a/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json +++ b/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json @@ -21,7 +21,7 @@ { "ordinal": 3, "name": "item_path", - "type_info": "Varchar" + "type_info": "Text" }, { "ordinal": 4, diff --git a/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json b/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json index 0f2c7ab318..fa4a6fc50e 100644 --- a/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json +++ b/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json @@ -21,7 +21,7 @@ { "ordinal": 3, "name": "item_path", - "type_info": "Varchar" + "type_info": "Text" }, { "ordinal": 4, diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be.json b/backend/.sqlx/query-6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be.json new file mode 100644 index 0000000000..6d6acec840 --- /dev/null +++ b/backend/.sqlx/query-6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_xact_lock(hashtext($1 || '/' || $2))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_xact_lock", + "type_info": "Void" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be" +} diff --git a/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json b/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json index 4692b430ec..9995bb1b51 100644 --- a/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json +++ b/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json @@ -13,7 +13,7 @@ "Left": [ "Varchar", "Varchar", - "Varchar", + "Text", "Jsonb", "Varchar" ] diff --git a/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json b/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json index 191630bd35..49fd50d7e6 100644 --- a/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json +++ b/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json @@ -21,7 +21,7 @@ { "ordinal": 3, "name": "item_path", - "type_info": "Varchar" + "type_info": "Text" }, { "ordinal": 4, diff --git a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json deleted file mode 100644 index 25a32e5338..0000000000 --- a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO deployment_metadata (workspace_id, path, script_hash, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a" -} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 2729357328..e5b66f5c28 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -605,7 +605,7 @@ impl HandleDeploymentMetadata { } async fn create_script_internal<'c>( - ns: NewScript, + mut ns: NewScript, w_id: String, authed: ApiAuthed, db: sqlx::Pool, @@ -675,6 +675,17 @@ async fn create_script_internal<'c>( .to_owned(), )); }; + // When auto_parent is set, serialize concurrent creates for the same (workspace, path) + // so the clashing_script query always sees the latest committed head. + if ns.auto_parent.unwrap_or(false) { + sqlx::query_scalar!( + "SELECT pg_advisory_xact_lock(hashtext($1 || '/' || $2))", + &w_id, + &ns.path + ) + .fetch_one(&mut *tx) + .await?; + } let clashing_script = sqlx::query_as::<_, Script>( "SELECT * FROM script WHERE path = $1 AND archived = false AND workspace_id = $2", ) @@ -687,6 +698,15 @@ async fn create_script_internal<'c>( perms: serde_json::Value, p_path: String, } + // When auto_parent is set, resolve parent_hash to the current head for this path + // within the transaction. The advisory lock above ensures the second concurrent + // request waits until the first commits, so this query sees the updated head. + if ns.auto_parent.unwrap_or(false) { + if let Some(ref cs) = clashing_script { + ns.parent_hash = Some(cs.hash.clone()); + } + } + let parent_hashes_and_perms: Option = match (&ns.parent_hash, clashing_script) { (None, None) => Ok(None), (None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!( diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 4e832b3a93..9f12ab4ee2 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -1690,6 +1690,18 @@ async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> { Ok(()) } +// Anchor the CE-only query for `cargo sqlx prepare` (which runs with --features enterprise) +#[cfg(feature = "enterprise")] +#[allow(dead_code)] +async fn _sqlx_anchor_ce_user_count(db: &DB, w_id: &str) { + let _ = sqlx::query_scalar!( + "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + w_id + ) + .fetch_one(db) + .await; +} + #[cfg(not(feature = "enterprise"))] async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> { let user_count: i64 = sqlx::query_scalar!( diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index d3035b1b49..50790fe65b 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -428,6 +428,7 @@ pub async fn clone_script<'c>( preserve_on_behalf_of: None, assets: s.assets, modules: s.modules, + auto_parent: None, }; let new_hash = hash_script(&ns); diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 5eca2be82c..de26f0e484 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -510,6 +510,8 @@ pub struct NewScript { pub assets: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub modules: Option>, + #[serde(default)] + pub auto_parent: Option, } // IMPORTANT: update this Hash impl when adding fields to NewScript diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 10015db776..dd0fd30470 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -494,6 +494,7 @@ export async function handleFile( const body = { ...requestBodyCommon, parent_hash: remote.hash, + auto_parent: true, }; const execTime = await createScript( bundleContent, From 8866bd44cffff21ad7cd50aae1326f546f3e3efd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 18:20:46 +0000 Subject: [PATCH 059/153] nit backend tests --- .../tests/scripts.rs | 155 +++++++++++++----- 1 file changed, 116 insertions(+), 39 deletions(-) diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index 74fd9c8611..f5e78f880f 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -108,7 +108,10 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await; assert_eq!(resp.status(), 200); let body = resp.text().await?; - assert!(body.contains("return 42"), "expected script content, got: {body}"); + assert!( + body.contains("return 42"), + "expected script content, got: {body}" + ); // --- raw by hash (requires .ts suffix) --- let resp = authed_get(port, "raw/h", &format!("{hash}.ts")).await; @@ -131,12 +134,10 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { assert!(list.iter().any(|s| s["path"] == "u/test-user/test_script")); // list with path_start filter - let resp = authed(client().get(format!( - "{base}/list?path_start=u/test-user/another" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{base}/list?path_start=u/test-user/another"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); let list = resp.json::>().await?; assert_eq!(list.len(), 1); @@ -233,12 +234,7 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { .send() .await .unwrap(); - assert_eq!( - resp.status(), - 200, - "history_update: {}", - resp.text().await? - ); + assert_eq!(resp.status(), 200, "history_update: {}", resp.text().await?); // --- toggle_workspace_error_handler (EE-gated, expect 400 in OSS) --- let resp = authed(client().post(script_url( @@ -268,22 +264,13 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { .send() .await .unwrap(); - assert_eq!( - resp.status(), - 200, - "tokened_raw: {}", - resp.text().await? - ); + assert_eq!(resp.status(), 200, "tokened_raw: {}", resp.text().await?); // --- archive by path --- - let resp = authed(client().post(script_url( - port, - "archive/p", - "u/test-user/another_script", - ))) - .send() - .await - .unwrap(); + let resp = authed(client().post(script_url(port, "archive/p", "u/test-user/another_script"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); // archived script should still be gettable @@ -333,12 +320,10 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { // ===== Hub endpoints (require external network, expect 500 or 200) ===== // --- hub/top --- - let resp = authed(client().get(format!( - "http://localhost:{port}/api/scripts/hub/top" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("http://localhost:{port}/api/scripts/hub/top"))) + .send() + .await + .unwrap(); assert!( resp.status() == 200 || resp.status() == 500, "hub/top: unexpected status {}", @@ -372,12 +357,10 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { ); // --- integrations hub/list --- - let resp = authed(client().get(format!( - "http://localhost:{port}/api/integrations/hub/list" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("http://localhost:{port}/api/integrations/hub/list"))) + .send() + .await + .unwrap(); assert!( resp.status() == 200 || resp.status() == 500, "integrations hub/list: unexpected status {}", @@ -386,3 +369,97 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_auto_parent_resolves_parent_hash(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/w/test-workspace/scripts"); + + // Create v1 + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script( + "u/test-user/auto_parent_test", + "v1", + "export async function main() { return 1; }", + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "create v1: {}", resp.text().await?); + + // Get the hash of v1 + let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await; + let body = resp.json::().await?; + let v1_hash = body["hash"].as_str().unwrap().to_string(); + + // Create v2 using auto_parent (no parent_hash provided) + let mut v2 = new_script( + "u/test-user/auto_parent_test", + "v2", + "export async function main() { return 2; }", + ); + v2["auto_parent"] = json!(true); + let resp = authed(client().post(format!("{base}/create"))) + .json(&v2) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 201, + "create v2 with auto_parent: {}", + resp.text().await? + ); + + // Get v2 and verify its parent_hash points to v1 + let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await; + let body = resp.json::().await?; + assert_eq!(body["summary"], "v2"); + let v2_hash = body["hash"].as_str().unwrap().to_string(); + assert_ne!(v2_hash, v1_hash); + + // v2's parent_hashes should contain v1 + let parent_hashes = body["parent_hashes"].as_array().unwrap(); + assert!( + parent_hashes + .iter() + .any(|h| h.as_str() == Some(v1_hash.as_str())), + "v2 parent_hashes should contain v1 hash {v1_hash}, got: {parent_hashes:?}" + ); + + // Create v3 with auto_parent to confirm it chains correctly + let mut v3 = new_script( + "u/test-user/auto_parent_test", + "v3", + "export async function main() { return 3; }", + ); + v3["auto_parent"] = json!(true); + let resp = authed(client().post(format!("{base}/create"))) + .json(&v3) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 201, + "create v3 with auto_parent: {}", + resp.text().await? + ); + + let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await; + let body = resp.json::().await?; + assert_eq!(body["summary"], "v3"); + + // v3's parent_hashes should contain v2 (and transitively v1) + let parent_hashes = body["parent_hashes"].as_array().unwrap(); + assert!( + parent_hashes + .iter() + .any(|h| h.as_str() == Some(v2_hash.as_str())), + "v3 parent_hashes should contain v2 hash {v2_hash}, got: {parent_hashes:?}" + ); + + Ok(()) +} From d760ea5eaf4dc33007f1fd3e5e07b86925a0aa11 Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:28:18 +0100 Subject: [PATCH 060/153] fix: add relative imports to the dependency list in deploymentUI (#8548) * prepare sqlx * Add relative imports to getDependencies of deployUI * nit * fix: correct get_imports doc comment, add tracing, use Set for dedup - Fix copy-pasted doc comment on get_imports (said "get dependents") - Add tracing::debug to get_imports handler to match get_dependents - Use Set for O(1) duplicate detection in deploy dependency traversal Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Ruben Fiszel Co-authored-by: Claude Opus 4.6 (1M context) --- ...20e383a998a54c95355bb85fe7e762a0d9765.json | 23 +++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 25 +++++++++++++++++++ backend/windmill-api/openapi.yaml | 24 ++++++++++++++++++ .../src/scoped_dependency_map.rs | 23 ++++++++++++++++- .../src/lib/components/DeployWorkspace.svelte | 12 +++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765.json diff --git a/backend/.sqlx/query-52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765.json b/backend/.sqlx/query-52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765.json new file mode 100644 index 0000000000..16ae512f37 --- /dev/null +++ b/backend/.sqlx/query-52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT imported_path as \"imported_path!\"\n FROM dependency_map\n WHERE workspace_id = $1\n AND importer_path = $2\n AND imported_path NOT LIKE 'dependencies/%'\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "imported_path!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765" +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 9f12ab4ee2..646a2369b9 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -79,6 +79,7 @@ pub fn workspaced_service() -> Router { .route("/rebuild_dependency_map", post(rebuild_dependency_map)) .route("/get_dependency_map", get(get_dependency_map)) .route("/get_dependents/*imported_path", get(get_dependents)) + .route("/get_imports/*importer_path", get(get_imports)) .route("/get_dependents_amounts", post(get_dependents_amounts)) .route("/get_settings", get(get_settings)) .route( @@ -4358,6 +4359,30 @@ async fn get_dependents( Ok(Json(dependents)) } +async fn get_imports( + Extension(db): Extension, + Path((w_id, importer_path)): Path<(String, String)>, + _authed: ApiAuthed, +) -> JsonResult> { + tracing::debug!( + workspace_id = %w_id, + importer_path = %importer_path, + "API: Getting imports for importer path" + ); + + let imports = ScopedDependencyMap::get_imports(&importer_path, &w_id, &db).await?; + + tracing::debug!( + workspace_id = %w_id, + importer_path = %importer_path, + imports_count = imports.len(), + "API: Found imports: {:?}", + imports + ); + + Ok(Json(imports)) +} + #[derive(Serialize, Debug)] struct DependentsAmount { imported_path: String, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2b83550ab2..1275d648c2 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2714,6 +2714,30 @@ paths: items: $ref: "#/components/schemas/DependencyDependent" + /w/{workspace}/workspaces/get_imports/{importer_path}: + get: + summary: get script imports for an importer path + operationId: getImports + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: importer_path + in: path + required: true + schema: + type: string + description: The script path to get imports for + responses: + "200": + description: list of imported script paths + content: + application/json: + schema: + type: array + items: + type: string + /w/{workspace}/workspaces/get_dependents_amounts: post: summary: get dependents amounts for multiple imported paths diff --git a/backend/windmill-dep-map/src/scoped_dependency_map.rs b/backend/windmill-dep-map/src/scoped_dependency_map.rs index 9821b8e610..fcee6fe5f1 100644 --- a/backend/windmill-dep-map/src/scoped_dependency_map.rs +++ b/backend/windmill-dep-map/src/scoped_dependency_map.rs @@ -445,7 +445,28 @@ SELECT importer_node_id, imported_path, imported_lockfile_hash } } - /// Get dependents of any imported path - returns scripts/flows/apps that depend on it + /// Get imports of a given importer path - returns paths that the importer depends on + pub async fn get_imports<'c>( + importer_path: &str, + workspace_id: &str, + e: impl PgExecutor<'c>, + ) -> Result> { + sqlx::query_scalar!( + r#" + SELECT DISTINCT imported_path as "imported_path!" + FROM dependency_map + WHERE workspace_id = $1 + AND importer_path = $2 + AND imported_path NOT LIKE 'dependencies/%' + "#, + workspace_id, + importer_path + ) + .fetch_all(e) + .await + .map_err(Error::from) + } + pub async fn get_dependents<'c>( imported_path: &str, workspace_id: &str, diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index 9f1fd71146..3645bd587b 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -262,13 +262,25 @@ return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!) } throw new Error('Missing trigger information') + } else if (kind == 'script') { + const imports = await WorkspaceService.getImports({ + workspace: $workspaceStore!, + importerPath: path + }) + return imports.map((importedPath) => ({ kind: 'script' as Kind, path: importedPath })) } return [] } let toProcess = [{ kind, path }] + let processedSet = new Set() let processed: { kind: Kind; path: string }[] = [] while (toProcess.length > 0) { const { kind, path } = toProcess.pop()! + const key = `${kind}:${path}` + if (processedSet.has(key)) { + continue + } + processedSet.add(key) toProcess.push(...(await rec(kind, path))) processed.push({ kind, path }) } From 264fa33917628f2eed4237787fa69fe48a471453 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 18:46:25 +0000 Subject: [PATCH 061/153] chore(main): release 1.666.0 (#8543) * chore(main): release 1.666.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 16 ++ 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, 110 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d6640b9f..3cc5b9ad23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.666.0](https://github.com/windmill-labs/windmill/compare/v1.665.0...v1.666.0) (2026-03-26) + + +### Features + +* add PDF input support to AI agent ([#8525](https://github.com/windmill-labs/windmill/issues/8525)) ([e44504c](https://github.com/windmill-labs/windmill/commit/e44504c6e93e7a4ee94ced03ab626b79a4fd0754)) + + +### Bug Fixes + +* add relative imports to the dependency list in deploymentUI ([#8548](https://github.com/windmill-labs/windmill/issues/8548)) ([d760ea5](https://github.com/windmill-labs/windmill/commit/d760ea5eaf4dc33007f1fd3e5e07b86925a0aa11)) +* filter null entries in FileUpload initialValue to prevent s3 access error ([#8544](https://github.com/windmill-labs/windmill/issues/8544)) ([1a73012](https://github.com/windmill-labs/windmill/commit/1a73012e0737a6ebea8307013dc0f79982269d91)) +* pass pre-bound TcpListener to run_server to fix Windows CI test race ([#8542](https://github.com/windmill-labs/windmill/issues/8542)) ([d7f4b95](https://github.com/windmill-labs/windmill/commit/d7f4b950ce6e966ed1b410e03d48fe96bc036e73)) +* resolve parent_hash race condition in sync push with auto_parent ([#8545](https://github.com/windmill-labs/windmill/issues/8545)) ([71549c3](https://github.com/windmill-labs/windmill/commit/71549c3db053bcc209c7065ac8cd42f1e8047cc3)) +* upload_s3_file not working in VS Code extension ([#8547](https://github.com/windmill-labs/windmill/issues/8547)) ([1fa4d91](https://github.com/windmill-labs/windmill/commit/1fa4d919b30ac9eff2d1789fba2695450ba115e7)) + ## [1.665.0](https://github.com/windmill-labs/windmill/compare/v1.664.0...v1.665.0) (2026-03-26) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 684fef7714..031344cc61 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2352,9 +2352,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -15059,9 +15059,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -15761,7 +15761,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-nats", @@ -15837,7 +15837,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15850,7 +15850,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "argon2", @@ -15991,7 +15991,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16014,7 +16014,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16027,7 +16027,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16053,7 +16053,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.665.0" +version = "1.666.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16063,7 +16063,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16080,7 +16080,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16103,7 +16103,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16126,7 +16126,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16142,7 +16142,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16162,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16182,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16196,7 +16196,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-nats", @@ -16225,7 +16225,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16250,7 +16250,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16268,7 +16268,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16290,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16310,7 +16310,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16340,7 +16340,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16367,7 +16367,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.665.0" +version = "1.666.0" dependencies = [ "lazy_static", "serde", @@ -16379,7 +16379,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.665.0" +version = "1.666.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16403,7 +16403,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16417,7 +16417,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16449,7 +16449,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.665.0" +version = "1.666.0" dependencies = [ "chrono", "lazy_static", @@ -16463,7 +16463,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16482,7 +16482,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.665.0" +version = "1.666.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16583,7 +16583,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.665.0" +version = "1.666.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16602,7 +16602,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.665.0" +version = "1.666.0" dependencies = [ "regex", "serde", @@ -16617,7 +16617,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16641,7 +16641,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "futures", @@ -16658,7 +16658,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.665.0" +version = "1.666.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16674,7 +16674,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -16695,7 +16695,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -16726,7 +16726,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-oauth2", @@ -16750,7 +16750,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-stream", @@ -16784,7 +16784,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "futures", @@ -16802,7 +16802,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.665.0" +version = "1.666.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16811,7 +16811,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "lazy_static", @@ -16823,7 +16823,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "serde_json", @@ -16835,7 +16835,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "gosyn", @@ -16847,7 +16847,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "lazy_static", @@ -16859,7 +16859,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "serde_json", @@ -16871,7 +16871,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "nu-parser", @@ -16882,7 +16882,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16893,7 +16893,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16905,7 +16905,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16916,7 +16916,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-recursion", @@ -16938,7 +16938,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "lazy_static", @@ -16952,7 +16952,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16969,7 +16969,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "lazy_static", @@ -16982,7 +16982,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "serde", @@ -16994,7 +16994,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "lazy_static", @@ -17012,7 +17012,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17028,7 +17028,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17044,7 +17044,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "serde", @@ -17055,7 +17055,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-recursion", @@ -17092,7 +17092,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "const_format", @@ -17130,7 +17130,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.665.0" +version = "1.666.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17141,7 +17141,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-recursion", @@ -17170,7 +17170,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17193,7 +17193,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17226,7 +17226,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17246,7 +17246,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17280,7 +17280,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17315,7 +17315,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17338,7 +17338,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17362,7 +17362,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-nats", @@ -17386,7 +17386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17421,7 +17421,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17449,7 +17449,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17472,7 +17472,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17491,7 +17491,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-once-cell", @@ -17599,7 +17599,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.665.0" +version = "1.666.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8204082a5b..ee1c26c9f7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.665.0" +version = "1.666.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.665.0" +version = "1.666.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1275d648c2..aa3505eec7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.665.0 + version: 1.666.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 9af7dc3af2..829bd04e08 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.665.0"; +export const VERSION = "v1.666.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 30dd5d17fd..23e921d0b8 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -68,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.665.0"; +export const VERSION = "1.666.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 914eb790d1..1c3a993774 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.665.0", + "version": "1.666.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.665.0", + "version": "1.666.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index dadb95ca8e..b7eed033fb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.665.0", + "version": "1.666.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 9dccc5a54f..d2a3a0360a 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.665.0" +wmill = ">=1.666.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 44f26b14b4..b6065e0a91 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.665.0 + version: 1.666.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 93de641acd..f68c5d40bc 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.665.0' + ModuleVersion = '1.666.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 5ae8ca2bbc..fdcefd7e6f 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.665.0" +version = "1.666.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 50ac3f664a..b75bb4c78c 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.665.0", + "version": "1.666.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 7b70788845..21a7e3055e 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.665.0", + "version": "1.666.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 433b0ebcf9..8a00e52c64 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.665.0 +1.666.0 From c0aafee9a9923d5dc2fa3b99da4378e923933a06 Mon Sep 17 00:00:00 2001 From: Tristan TR <69242752+tristantr@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:52:15 +0100 Subject: [PATCH 062/153] feat: improve-replay-ui (#8250) * Improve UI of script record * Improve UI for scripts * Remove Result & Logs loading container while flow not finised * Improve Graph view * Add click on a step mention * Fix spacing when empty * Fix step duration disappearing in recorded flows * Modernize timeline tab * Improve Script recording result UI * feat: externalize recording player controls for fake-window embedding Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: reorder FlowViewer tab sync effects for clarity Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: eliminate tab sync effects in FlowViewer, use selectedTab directly Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove unnecessary untrack in FlowViewer tab init Co-Authored-By: Claude Opus 4.6 (1M context) * fix: skip tab auto-selection when selectedTab is controlled externally Co-Authored-By: Claude Opus 4.6 (1M context) * feat: export recording types from package Co-Authored-By: Claude Opus 4.6 (1M context) * fix: non-null assertion for recording.flow in FlowGraphViewer Co-Authored-By: Claude Opus 4.6 (1M context) * fix: replace banned $bindable(default_value) pattern and simplify tab sync Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use svelte 5 onclick syntax on replay page Co-Authored-By: Claude Opus 4.6 (1M context) * fix: skip db clock endpoint during replay mode Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove line numbers from script recording code display Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: hugocasa Co-authored-by: Claude Opus 4.6 (1M context) --- frontend/package.json | 7 + .../src/lib/components/FlowGraphViewer.svelte | 12 +- .../lib/components/FlowGraphViewerStep.svelte | 7 +- .../lib/components/FlowStatusViewer.svelte | 2 + .../components/FlowStatusViewerInner.svelte | 8 +- .../src/lib/components/FlowTimeline.svelte | 160 ++++++------ frontend/src/lib/components/FlowViewer.svelte | 76 ++++-- frontend/src/lib/components/JobLoader.svelte | 4 +- .../src/lib/components/ScriptEditor.svelte | 15 +- .../src/lib/components/TimelineBar.svelte | 24 +- .../lib/components/graph/FlowGraphV2.svelte | 1 + .../recording/FlowRecordingReplay.svelte | 182 +++++++++----- .../recording/ScriptRecordingReplay.svelte | 237 ++++++++++++------ frontend/src/lib/forLater.ts | 4 + .../(root)/(logged)/replay/+page.svelte | 11 +- 15 files changed, 457 insertions(+), 293 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index b7eed033fb..14e7119c7a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -295,6 +295,10 @@ "svelte": "./package/components/recording/ScriptRecordingReplay.svelte", "default": "./package/components/recording/ScriptRecordingReplay.svelte" }, + "./components/recording/types": { + "types": "./package/components/recording/types.d.ts", + "default": "./package/components/recording/types.js" + }, "./components/FlowWrapper.svelte": { "types": "./package/components/FlowWrapper.svelte.d.ts", "svelte": "./package/components/FlowWrapper.svelte", @@ -500,6 +504,9 @@ "components/ScriptRecordingReplay.svelte": [ "./package/components/recording/ScriptRecordingReplay.svelte.d.ts" ], + "components/recording/types": [ + "./package/components/recording/types.d.ts" + ], "components/FlowBuilder.svelte": [ "./package/components/FlowBuilder.svelte.d.ts" ], diff --git a/frontend/src/lib/components/FlowGraphViewer.svelte b/frontend/src/lib/components/FlowGraphViewer.svelte index 9bfbca3ee4..62e5bdac8e 100644 --- a/frontend/src/lib/components/FlowGraphViewer.svelte +++ b/frontend/src/lib/components/FlowGraphViewer.svelte @@ -26,6 +26,7 @@ workspace?: string | undefined minHeight?: number noBorder?: boolean + hideDefaultInputs?: boolean } let { @@ -38,7 +39,8 @@ stepDetail = $bindable(undefined), workspace = $workspaceStore, minHeight = 400, - noBorder = false + noBorder = false, + hideDefaultInputs = false }: Props = $props() const dispatch = createEventDispatcher() @@ -47,7 +49,9 @@
    {#if !noGraph}
    @@ -81,14 +85,14 @@ />
    {/if} - {#if !noSide} + {#if !noSide && !(hideDefaultInputs && stepDetail == undefined)} {/if}
    diff --git a/frontend/src/lib/components/FlowGraphViewerStep.svelte b/frontend/src/lib/components/FlowGraphViewerStep.svelte index 8c560abef0..077edc63e6 100644 --- a/frontend/src/lib/components/FlowGraphViewerStep.svelte +++ b/frontend/src/lib/components/FlowGraphViewerStep.svelte @@ -23,9 +23,10 @@ schema?: any | undefined stepDetail?: FlowModule | string | undefined jobScriptHash?: string | undefined + hideDefaultInputs?: boolean } - let { schema = undefined, stepDetail = undefined, jobScriptHash = undefined }: Props = $props() + let { schema = undefined, stepDetail = undefined, jobScriptHash = undefined, hideDefaultInputs = false }: Props = $props() let codeViewer: Drawer | undefined = $state() @@ -92,10 +93,10 @@
    {#if stepDetail == undefined}
    -

    +

    Click on a step to see its details

    - {#if schema} + {#if schema && !hideDefaultInputs}

    Flow Inputs

    {/if} diff --git a/frontend/src/lib/components/FlowStatusViewer.svelte b/frontend/src/lib/components/FlowStatusViewer.svelte index 1642193a46..4b4e08c1b9 100644 --- a/frontend/src/lib/components/FlowStatusViewer.svelte +++ b/frontend/src/lib/components/FlowStatusViewer.svelte @@ -44,6 +44,7 @@ workspaceId = undefined, flowState = $bindable({}), selectedJobStep = $bindable(undefined), + hideFlowResult = false, hideTimeline = false, hideDownloadInGraph = false, hideNodeDefinition = false, @@ -175,6 +176,7 @@ } }} {showLogsWithResult} + {hideFlowResult} notes={notesProp} groups={groupsProp} /> diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 8aaeadb5ef..d35ae53c47 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -136,6 +136,7 @@ } showLogsWithResult?: boolean showJobDetailHeader?: boolean + hideFlowResult?: boolean notes?: FlowNote[] groups?: FlowValue['groups'] } @@ -178,6 +179,7 @@ toolCallStore, showLogsWithResult = false, showJobDetailHeader = false, + hideFlowResult = false, notes: notesProp = undefined, groups: groupsProp = undefined }: Props = $props() @@ -1356,7 +1358,7 @@ />
    {/if} - {:else if render} + {:else if render && !hideFlowResult}
    {#if showLogsWithResult && job} @@ -2141,7 +2143,7 @@ likely did not run yet

    {/if} - {:else}

    Select a node to see its details here

    {/if}
    @@ -2157,7 +2159,7 @@ {#if node?.job_id} {:else} -
    Select a node with a job to see HTTP request traces
    {/if} diff --git a/frontend/src/lib/components/FlowTimeline.svelte b/frontend/src/lib/components/FlowTimeline.svelte index 3fbb69503a..9bff159a03 100644 --- a/frontend/src/lib/components/FlowTimeline.svelte +++ b/frontend/src/lib/components/FlowTimeline.svelte @@ -81,36 +81,31 @@ }} /> {#if items} -
    -
    -
    {min ? displayDate(new Date(min), true) : ''}
    {#if max && min} - {/if}
    {max ? displayDate(new Date(max), true) : ''}{#if !max && min}{#if now} - {msToSec(now - min, 1)}s - {/if}{/if}
    -
    -
    -
    -
    -
    Waiting for executor/Suspend
    -
    +
    +
    +
    + {min ? displayDate(new Date(min), true) : ''} +
    +
    +
    +
    + Wait
    - -
    -
    Execution
    -
    +
    +
    + Execution
    + {#if max && min} + {msToSec(max - min, 1)}s + {/if} + {#if !max && min}{#if now} + {msToSec(now - min, 1)}s + {/if}{/if}
    {#if selfWaitTime} -
    - root: +
    + root: x.created_at && x.started_at)} -
    -
    -
    +
    +
    {k.startsWith('subflow:') ? k.substring(8) : k} {#if localModuleStates[k]?.selectedForloop && (typ == 'forloopflow' || typ == 'whileloopflow')} @@ -141,70 +136,67 @@ {/if}
    -
    - {#if subItems?.length > 1} -
    - {subItems?.length} jobs -
    - {/if} - {#if min && total} - subItems?.[index]?.id} - > - {#snippet item({ index, style })} - {@const b = subItems?.[index]} - {#if b?.created_at} - - {@const waitingLen = b?.created_at - ? b.started_at - ? b.started_at - b?.created_at - : b.duration_ms - ? 0 - : now - b?.created_at - : 0} -
    + {#if subItems?.length > 1} + + {subItems?.length} jobs + + {/if} +
    +
    + {#if min && total} + subItems?.[index]?.id} + > + {#snippet item({ index, style })} + {@const b = subItems?.[index]} + {#if b?.created_at} + {@const waitingLen = b?.created_at + ? b.started_at + ? b.started_at - b?.created_at + : b.duration_ms + ? 0 + : now - b?.created_at + : 0} +
    + + {#if b.started_at} - {#if b.started_at} - - {/if} -
    - {:else} -
    -
    - -
    -
    - {/if} - {/snippet} -
    - {/if}
    + {/if} +
    + {:else} +
    + {/if} + {/snippet} + + {/if} +
    {/each}
    + {:else} {/if} diff --git a/frontend/src/lib/components/FlowViewer.svelte b/frontend/src/lib/components/FlowViewer.svelte index 2355ddf68d..1e321584e3 100644 --- a/frontend/src/lib/components/FlowViewer.svelte +++ b/frontend/src/lib/components/FlowViewer.svelte @@ -20,7 +20,7 @@ schema?: any } - type TabValue = 'ui' | 'raw' | 'schema' | 'diff' + export type TabValue = 'ui' | 'raw' | 'schema' | 'diff' interface Props { flow: { @@ -33,10 +33,16 @@ noSide?: boolean noGraph?: boolean initTab?: TabValue + selectedTab?: TabValue + hideTabs?: boolean noSummary?: boolean + noInput?: boolean + hideDefaultInputs?: boolean + showStepHint?: boolean noGraphDownload?: boolean availableVersions?: Array<{ id: number; deployment_msg?: string }> selectedVersionId?: number + graphContent?: import('svelte').Snippet } let { @@ -46,9 +52,15 @@ noGraph = false, availableVersions = undefined, initTab = undefined, + selectedTab = $bindable(), + hideTabs = false, noSummary = false, + noInput = false, + hideDefaultInputs = false, + showStepHint = false, noGraphDownload = false, - selectedVersionId = undefined + selectedVersionId = undefined, + graphContent = undefined }: Props = $props() let open: { [id: number]: boolean } = {} @@ -59,7 +71,10 @@ let previousVersionId: number | undefined = $state(undefined) let previousFlow: PreviousFlow | undefined = $state(undefined) - let tab: TabValue = $state(untrack(() => initTab) ?? 'diff') + const tabControlledExternally = selectedTab !== undefined + if (!tabControlledExternally) { + selectedTab = initTab ?? 'diff' + } let previousFlowCache: Record = {} @@ -90,16 +105,16 @@ }) $effect.pre(() => { - if (initTab) { + if (initTab || tabControlledExternally) { return } if (availableVersions && availableVersions.length > 0) { - tab = 'diff' + selectedTab = 'diff' } else { if (noGraph) { - tab = 'schema' + selectedTab = 'schema' } else { - tab = 'ui' + selectedTab = 'ui' } } }) @@ -127,7 +142,7 @@ - + {#if availableVersions && availableVersions.length > 0} {/if} @@ -167,23 +182,38 @@ {/if} -
    - {#if !noSummary} -

    {flow.summary}

    -
    {flow.description ?? ''}
    - {/if} + {#if graphContent} + {@render graphContent()} + {:else} +
    + {#if showStepHint} +

    Click on a step to see its details

    + {/if} + {#if !noSummary} +

    {flow.summary}

    +
    {flow.description ?? ''}
    + {/if} -

    - Flow Input -

    - {#if flow.schema && flow.schema.properties && Object.keys(flow.schema.properties).length > 0 && flow.schema} - - {:else} -
    No inputs
    - {/if} + {#if !noInput} +

    + Flow Input +

    + {#if flow.schema && flow.schema.properties && Object.keys(flow.schema.properties).length > 0 && flow.schema} + + {:else} +
    No inputs
    + {/if} + {/if} - -
    + +
    + {/if}
    diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index d8a0adfd35..78429177a7 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -107,7 +107,7 @@ $effect(() => { if (noLogs != lastNoLogs) { lastNoLogs = noLogs - if (!noLogs) { + if (!noLogs && !getActiveReplay()) { currentEventSource?.onerror?.(new Event(noLogsChangeRestartEvent)) const lastJobId = lastCompletedJobId if (lastJobId && (job || lastCallbacks?.loadExtraLogs)) { @@ -255,7 +255,7 @@ } } export async function getLogs() { - if (job) { + if (job && !getActiveReplay()) { refreshLogOffset() const getUpdate = await JobService.getJobUpdates({ workspace: workspace!, diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 2690301f28..2bc5fe8575 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -632,6 +632,10 @@ } export async function runTest() { + // Discard any previous recording when running a normal test + if (!scriptRecording.active) { + lastRecording = undefined + } // Not defined if JobProgressBar not loaded jobProgressBar?.reset() // Flush module edits back to modules map before running preview @@ -1530,16 +1534,7 @@ displayName: 'Test & record', icon: Disc, action: () => recordAndTest() - }, - ...(lastRecording - ? [ - { - displayName: 'Download recording', - icon: Download, - action: () => downloadRecording() - } - ] - : []) + } ]} />
    diff --git a/frontend/src/lib/components/TimelineBar.svelte b/frontend/src/lib/components/TimelineBar.svelte index e4fb511450..5d1f53e961 100644 --- a/frontend/src/lib/components/TimelineBar.svelte +++ b/frontend/src/lib/components/TimelineBar.svelte @@ -14,6 +14,7 @@ running: boolean concat?: boolean gray?: boolean + spacerClass?: string } let { @@ -25,25 +26,26 @@ id, running, concat = false, - gray = false + gray = false, + spacerClass = '' }: Props = $props() {#if min && started_at != undefined} {#if !concat} -
    +
    {/if} {#snippet text()} 0} {@const narrow = len / total < 0.09} - {@const endPos = started_at != undefined && min != undefined ? (started_at - min + len) / total : 1} + {@const endPos = + started_at != undefined && min != undefined ? (started_at - min + len) / total : 1} {@const nearStart = endPos < 0.15} - {#if len}{msToSec(len, 1)}s{/if} {/if} diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 6a4d208579..47626fb41c 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -921,6 +921,7 @@ document.addEventListener('keydown', globalKeyDownHandler) + return () => { document.removeEventListener('keydown', globalKeyDownHandler) } diff --git a/frontend/src/lib/components/recording/FlowRecordingReplay.svelte b/frontend/src/lib/components/recording/FlowRecordingReplay.svelte index c5d94a7b95..afd2cee94c 100644 --- a/frontend/src/lib/components/recording/FlowRecordingReplay.svelte +++ b/frontend/src/lib/components/recording/FlowRecordingReplay.svelte @@ -2,7 +2,8 @@ import type { Job } from '$lib/gen' import { workspaceStore } from '$lib/stores' import FlowStatusViewer from '$lib/components/FlowStatusViewer.svelte' - import FlowViewer from '$lib/components/FlowViewer.svelte' + import FlowViewer, { type TabValue } from '$lib/components/FlowViewer.svelte' + import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte' import FlowProgressBar from '$lib/components/flows/FlowProgressBar.svelte' import FlowExecutionStatus from '$lib/components/runs/FlowExecutionStatus.svelte' import { setActiveReplay } from './flowRecording.svelte' @@ -13,21 +14,37 @@ import { InfoIcon, LogOut, Play, Square } from 'lucide-svelte' import { onDestroy } from 'svelte' - interface Props { - recording: FlowRecording - } - - let { recording }: Props = $props() - type ReplayState = 'loaded' | 'playing' - let replayState: ReplayState = $state('loaded') + interface Props { + recording: FlowRecording + selectedTab?: TabValue + replayState?: ReplayState + hideControls?: boolean + hideTabs?: boolean + } + + let { + recording, + selectedTab = $bindable(), + replayState = $bindable(), + hideControls = false, + hideTabs = false + }: Props = $props() + + if (selectedTab === undefined) { + selectedTab = 'ui' + } + if (replayState === undefined) { + replayState = 'loaded' + } + let rootJobId: string | undefined = $state(undefined) let rootInitialJob: Job | undefined = $state(undefined) let job: Job | undefined = $state(undefined) let done = $derived((job as any)?.type === 'CompletedJob') - function stop() { + export function stop() { setActiveReplay(undefined) job = undefined initRecording() @@ -36,10 +53,7 @@ function findRootJobId(data: FlowRecording): string | undefined { for (const [id, recorded] of Object.entries(data.jobs)) { const j = recorded.initial_job - if ( - (j.job_kind === 'flow' || j.job_kind === 'flowpreview') && - !j.parent_job - ) { + if ((j.job_kind === 'flow' || j.job_kind === 'flowpreview') && !j.parent_job) { return id } } @@ -81,17 +95,19 @@ for (const mod of fs.modules) { const durations = mod.flow_jobs_duration if (durations?.started_at) { - durations.started_at = durations.started_at.map( - (d: string) => offsetDate(d) ?? d - ) + durations.started_at = durations.started_at.map((d: string) => offsetDate(d) ?? d) } } } for (const recorded of Object.values(data.jobs)) { offsetJobTimestamps(recorded.initial_job) + if (recorded.initial_job?.flow_status) offsetFlowStatus(recorded.initial_job.flow_status) for (const event of recorded.events) { - if (event.data?.job) offsetJobTimestamps(event.data.job) + if (event.data?.job) { + offsetJobTimestamps(event.data.job) + if (event.data.job.flow_status) offsetFlowStatus(event.data.job.flow_status) + } if (event.data?.flow_status) offsetFlowStatus(event.data.flow_status) } } @@ -141,22 +157,27 @@ // Push the root's completed event to fire after all sub-job events let completedIdx = -1 for (let i = rootEvents.length - 1; i >= 0; i--) { - if (rootEvents[i].data.completed) { completedIdx = i; break } + if (rootEvents[i].data.completed) { + completedIdx = i + break + } } if (completedIdx >= 0 && rootEvents[completedIdx].t < maxSubJobT) { rootEvents[completedIdx].t = maxSubJobT + 50 } } - function startReplay() { + export function startReplay() { + if (!rootJobId) return // JSON round-trip to unwrap reactive proxies and strip non-cloneable properties const snapshot = JSON.parse(JSON.stringify(recording)) as FlowRecording - fixEventOrdering(snapshot, rootJobId!) - rebaseTimestamps(snapshot, rootJobId!) + fixEventOrdering(snapshot, rootJobId) + rebaseTimestamps(snapshot, rootJobId) setActiveReplay(snapshot) - rootInitialJob = buildInitialJob(snapshot, rootJobId!) + rootInitialJob = buildInitialJob(snapshot, rootJobId) job = undefined replayState = 'playing' + selectedTab = 'ui' } onDestroy(() => { @@ -173,52 +194,81 @@

    -{:else if replayState === 'loaded'} +{:else}
    -
    -
    -

    {recording.flow_path}

    - - - {#snippet text()} - - Recorded {new Date(recording.recorded_at).toLocaleString()} — - {(recording.total_duration_ms / 1000).toFixed(1)}s - - {/snippet} - + {#if !hideControls} +
    +
    +

    + {replayState === 'playing' ? 'Replaying: ' : ''}{recording.flow_path} +

    + + + {#snippet text()} + + Recorded {new Date(recording.recorded_at).toLocaleString()} — + {(recording.total_duration_ms / 1000).toFixed(1)}s + + {/snippet} + +
    + {#if replayState === 'loaded'} + + {:else} + + {/if}
    - -
    - -
    -{:else if replayState === 'playing' && rootJobId} -
    -
    -

    Replaying: {recording.flow_path}

    - -
    - - {#if job} - {/if} - + + + {#snippet graphContent()} + {#if replayState === 'playing' && rootJobId} +
    + + {#if job} + + {/if} + +
    + {:else} +
    +

    Click on a step to see its details

    + +
    + {/if} + {/snippet} +
    {/if} diff --git a/frontend/src/lib/components/recording/ScriptRecordingReplay.svelte b/frontend/src/lib/components/recording/ScriptRecordingReplay.svelte index 8c3d28a71a..0327044b3b 100644 --- a/frontend/src/lib/components/recording/ScriptRecordingReplay.svelte +++ b/frontend/src/lib/components/recording/ScriptRecordingReplay.svelte @@ -12,6 +12,7 @@ import { json as jsonLang } from 'svelte-highlight/languages' import HighlightTheme from '$lib/components/HighlightTheme.svelte' import JobArgs from '$lib/components/JobArgs.svelte' + import SchemaForm from '$lib/components/SchemaForm.svelte' import DisplayResult from '$lib/components/DisplayResult.svelte' import LogViewer from '$lib/components/LogViewer.svelte' import { ClipboardCopy, InfoIcon, LogOut, Play, Square } from 'lucide-svelte' @@ -19,15 +20,26 @@ import { onDestroy, tick } from 'svelte' import JobLoader from '$lib/components/JobLoader.svelte' - interface Props { - recording: ScriptRecording - } - - let { recording }: Props = $props() + export type ScriptTabValue = 'parameters' | 'code' | 'args' | 'schema' | 'result' type ReplayState = 'loaded' | 'playing' - let replayState: ReplayState = $state('loaded') + interface Props { + recording: ScriptRecording + selectedTab?: ScriptTabValue + replayState?: ReplayState + hideControls?: boolean + hideTabs?: boolean + } + + let { + recording, + selectedTab = $bindable(), + replayState = $bindable(), + hideControls = false, + hideTabs = false + }: Props = $props() + let jobId: string | undefined = $state(undefined) let job: Job | undefined = $state(undefined) let jobLoader: JobLoader | undefined = $state(undefined) @@ -35,7 +47,18 @@ let scriptRecordingStore = createScriptRecording() - function stop() { + let schema = $derived(recording.schema) + + if (selectedTab === undefined) { + if (schema && recording.args) selectedTab = 'parameters' + else if (recording.args && Object.keys(recording.args).length > 0) selectedTab = 'args' + else selectedTab = 'code' + } + if (replayState === undefined) { + replayState = 'loaded' + } + + export function stop() { setActiveReplay(undefined) job = undefined replayState = 'loaded' @@ -85,13 +108,14 @@ initRecording() - async function startReplay() { + export async function startReplay() { const snapshot = JSON.parse(JSON.stringify(recording)) as ScriptRecording rebaseTimestamps(snapshot) const replayData = scriptRecordingStore.toReplayData(snapshot) setActiveReplay(replayData) job = undefined replayState = 'playing' + selectedTab = 'result' await tick() if (jobLoader && jobId) { jobLoader.watchJob(jobId) @@ -101,8 +125,6 @@ onDestroy(() => { setActiveReplay(undefined) }) - - let schema = $derived(recording.schema) @@ -115,48 +137,141 @@

    -{:else if replayState === 'loaded'} -
    -
    -
    -

    {recording.script_path || 'Untitled script'}

    - {recording.language} - - - {#snippet text()} - - Recorded {new Date(recording.recorded_at).toLocaleString()} — - {(recording.total_duration_ms / 1000).toFixed(1)}s - - {/snippet} - +{:else} +
    + {#if !hideControls} +
    +
    +

    + {replayState === 'playing' ? 'Replaying: ' : ''}{recording.script_path || + 'Untitled script'} +

    + + {recording.language} + + + + {#snippet text()} + + Recorded {new Date(recording.recorded_at).toLocaleString()} — + {(recording.total_duration_ms / 1000).toFixed(1)}s + + {/snippet} + +
    + {#if replayState === 'loaded'} + + {:else} + + {/if}
    - -
    - - {#if recording.args && Object.keys(recording.args).length > 0} - {/if} - - + {#if replayState === 'playing'} + + {/if} + + + {#if replayState === 'playing'} + + {/if} + {#if schema && recording.args} + + {/if} + {#if recording.args && Object.keys(recording.args).length > 0} + + {/if} + {#if !schema || !recording.args} + + {/if} {#if schema} {/if} {#snippet content()} + + {#if replayState === 'playing' && jobId} +
    +
    +

    Result

    +
    + {#if job !== undefined && job.type === 'CompletedJob' && job.result !== undefined} + + {:else if done} +
    + No output available +
    + {:else} +
    + Waiting for result... +
    + {/if} +
    +
    +
    +

    Logs

    +
    + +
    +
    +
    + {/if} +
    + + {#if schema && recording.args} +
    +
    + +
    +
    + +
    +
    + {/if} +
    + + {#if recording.args && Object.keys(recording.args).length > 0} +
    + +
    + {/if} +
    -
    +
    @@ -180,46 +295,4 @@ {/snippet}
    -{:else if replayState === 'playing' && jobId} -
    -
    -

    Replaying: {recording.script_path || 'Untitled script'}

    - -
    - - - {#if done && job} -
    -

    Result

    -
    - {#if job.type === 'CompletedJob' && job.result !== undefined} - - {:else} -
    No result available
    - {/if} -
    -
    - {/if} - -
    - -
    -
    {/if} diff --git a/frontend/src/lib/forLater.ts b/frontend/src/lib/forLater.ts index ea290e2a53..b3ce8001e2 100644 --- a/frontend/src/lib/forLater.ts +++ b/frontend/src/lib/forLater.ts @@ -1,6 +1,7 @@ import { get } from 'svelte/store' import { dbClockDrift } from './stores' import { JobService } from './gen' +import { getActiveReplay } from './components/recording/flowRecording.svelte' import pLimit from 'p-limit' function subtractSeconds(date: Date, seconds: number): Date { @@ -26,6 +27,9 @@ export function forLater(scheduledString: string): boolean { const limit = pLimit(1) export function getDbClockNow() { + if (getActiveReplay()) { + return new Date() + } let drift = get(dbClockDrift) if (drift == undefined) { limit(() => computeDrift()) diff --git a/frontend/src/routes/(root)/(logged)/replay/+page.svelte b/frontend/src/routes/(root)/(logged)/replay/+page.svelte index 559961aff0..d89bcbb9b0 100644 --- a/frontend/src/routes/(root)/(logged)/replay/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/replay/+page.svelte @@ -51,14 +51,14 @@
    {#if flowRecording}
    -
    {:else if scriptRecording}
    -
    @@ -70,12 +70,7 @@

    Upload a recording JSON file to replay a flow or script execution offline.

    - + Drag and drop a recording file
    From e2cc6e4709404e14ff23a515001ba931caba95dd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 20:58:23 +0000 Subject: [PATCH 063/153] nit sqlx --- ...1c2d14755474cba82b3b388a47585a8bb325b1a.json | 17 +++++++++++++++++ backend/ee-repo-ref.txt | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json diff --git a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json new file mode 100644 index 0000000000..25a32e5338 --- /dev/null +++ b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO deployment_metadata (workspace_id, path, script_hash, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ce0a80c162..840129b249 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -6db424512b0d02f86489e85f0026581b7637d6e6 +01688af32ccd48a39f993043c1ce8f337b5c9eff \ No newline at end of file From 9e235937ce41323c83815f08a99c5ce9e4840b6b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 08:53:46 +0000 Subject: [PATCH 064/153] add WAC v2 benchmarks and improve benchmark infrastructure (#8550) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/benchmark.yml | 44 +++++++ benchmarks/Dockerfile | 16 ++- benchmarks/README.md | 122 ++++++++---------- benchmarks/benchmark_graphs.ts | 34 ++--- benchmarks/benchmark_oneoff.ts | 37 +++++- benchmarks/benchmark_suite.ts | 13 +- benchmarks/graphs_config.json | 70 ++++++++++ benchmarks/lib.ts | 220 ++++++++++++++++++++++++++++++++ benchmarks/main.ts | 20 --- benchmarks/suite_wac.json | 30 +++++ benchmarks/worker.ts | 90 ------------- 11 files changed, 474 insertions(+), 222 deletions(-) create mode 100644 benchmarks/suite_wac.json diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 195821b2dd..d420ff1f00 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -290,6 +290,49 @@ jobs: path: | *.json + benchmark_wac: + runs-on: ubicloud-standard-8 + services: + postgres: + image: postgres + env: + POSTGRES_DB: windmill + POSTGRES_PASSWORD: changeme + POSTGRES_INITDB_ARGS: "-c shared_buffers=2GB -c work_mem=32MB -c effective_cache_size=4GB" + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s + --health-retries 5 + --shm-size=2g + windmill: + image: ghcr.io/windmill-labs/windmill-ee:main + env: + DATABASE_URL: postgres://postgres:changeme@postgres:5432/windmill + LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} + WORKER_GROUP: main + WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + options: >- + --pull always --health-interval 10s --health-timeout 5s + --health-retries 5 --health-cmd "curl + http://localhost:8000/api/version" + ports: + - 8000:8000 + steps: + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: benchmark + timeout-minutes: 30 + run: deno run -A -r + https://raw.githubusercontent.com/windmill-labs/windmill/${GITHUB_REF##ref/head/}/benchmarks/benchmark_suite.ts + -c + https://raw.githubusercontent.com/windmill-labs/windmill/${GITHUB_REF##ref/head/}/benchmarks/suite_wac.json + - name: Save benchmark results + uses: actions/upload-artifact@v4 + with: + name: benchmark_wac + path: | + *.json + benchmark_graphs: runs-on: ubicloud needs: @@ -297,6 +340,7 @@ jobs: - benchmark_dedicated - benchmark_4workers - benchmark_8workers + - benchmark_wac steps: - uses: denoland/setup-deno@v2 with: diff --git a/benchmarks/Dockerfile b/benchmarks/Dockerfile index c8f3fe83d5..7655e5f2d2 100644 --- a/benchmarks/Dockerfile +++ b/benchmarks/Dockerfile @@ -1,14 +1,20 @@ -FROM denoland/deno:alpine-1.26.2 +FROM denoland/deno:alpine-2.1.4 WORKDIR /app USER deno +ADD ./lib.ts . +ADD ./action.ts . ADD ./main.ts . -RUN deno cache --unstable main.ts +RUN deno cache main.ts ADD ./worker.ts . -RUN deno cache --unstable worker.ts +RUN deno cache worker.ts ADD ./scraper.ts . -RUN deno cache --unstable scraper.ts +RUN deno cache scraper.ts +ADD ./benchmark_oneoff.ts . +RUN deno cache benchmark_oneoff.ts +ADD ./benchmark_suite.ts . +RUN deno cache benchmark_suite.ts -ENTRYPOINT [ "/tini", "--", "docker-entrypoint.sh", "run", "--unstable", "-A", "main.ts" ] \ No newline at end of file +ENTRYPOINT [ "/tini", "--", "docker-entrypoint.sh", "run", "-A", "main.ts" ] diff --git a/benchmarks/README.md b/benchmarks/README.md index c358471626..fc758c006f 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,85 +1,71 @@ # Benchmarks -This folder includes a small deno/ts utility to benchmark execution of jobs & -flows. +Deno/TS benchmark suite for measuring Windmill job and flow execution throughput. -## Installation +## Quick Start -Install the `wmill` CLI tool using -`deno install --unstable -A https://deno.land/x/wmillbench/main.ts`. +```bash +# Install Deno +curl -fsSL https://deno.land/install.sh | sh -Update to the latest version using `wmillbench upgrade`. +# Run a single benchmark +deno run -A benchmark_oneoff.ts --kind noop --jobs 10000 -To build a local version, you can just run: -``` -deno install -A main.ts +# Run the full suite +deno run -A benchmark_suite.ts -c suite_config.json + +# Run WAC v2 benchmarks (workflow-as-code vs flow comparison) +deno run -A benchmark_suite.ts -c suite_wac.json ``` -## Quickstart +## Benchmark Kinds -Have your instance expose prometheus metrics (METRICS_ADDR=true). +### Script benchmarks +- `noop` — Empty jobs (measures pure scheduling overhead) +- `deno`, `bun`, `python`, `go`, `bash` — Language runtimes +- `nativets` — BunNative (no isolation) +- `dedicated`, `dedicated_nativets` — Dedicated worker mode -Then +### Flow benchmarks +- `2steps` — 2-step flow (deno + identity) +- `bigscriptinflow` — Flow with large raw bash script +- `flow_seq_2_bun` — 2 sequential bun steps +- `flow_par_2_bun` — 2 parallel bun steps (branchall) +- `flow_seq_3_bun` — 3 sequential bun steps +- `flow:` — Custom flow by path +- `script:` — Custom script by path -``` -wmillbench -e admin@windmill.dev -p changeme --host YOUR_HOST +### WAC v2 benchmarks (workflow-as-code) +- `wac_seq_2` — 2 sequential tasks +- `wac_par_2` — 2 parallel tasks (Promise.all) +- `wac_seq_3` — 3 sequential tasks +- `wac_inline_2` — 2 inline steps (no child jobs) + +## Suite Configs + +| File | Description | +|------|-------------| +| `suite_config.json` | Main benchmark suite (noop, languages, flows) | +| `suite_dedicated.json` | Dedicated worker benchmarks | +| `suite_dedicated_nativets.json` | Dedicated NativeTS benchmarks | +| `suite_wac.json` | WAC v2 vs flow comparison benchmarks | + +## Interactive Benchmark Tool + +```bash +deno run -A main.ts -e admin@windmill.dev -p changeme --host http://localhost:8000 ``` -## Usage +Options: `--workers`, `--seconds`, `--maximum-throughput`, `--use-flows`, `--script-pattern`, `--export-json`, `--export-csv` -Usage: wmillbench +## Graph Generation -Description: - -Run Benchmark to measure throughput of windmill. - -Options: - --h, --help - Show this help. --V, --version - Show the version number for this program. ---host - The windmill host to benchmark. (Default: "http://127.0.0.1:8000/") ---workers - The number of workers to run at once. (Default: 1) --s, --seconds - How long to run the benchmark for (in seconds). (Default: 30) --e, --email - The email to use to login. --p, --password - The password to use to login. --t, --token - The token to use when talking to the API server. Preferred over manual login. --w, --workspace - The workspace to spawn scripts from. (Default: "starter") --m, --metrics - The url to scrape metrics from. (Default: "http://localhost:8001/metrics") ---export-json - If set, exports will be into a JSON file. ---export-csv - If set, exports will be into a csv file. ---export-histograms [histograms...] - Mark metrics (without label) that are reported as histograms to export. ---export-simple [simple...] - Mark metrics (without label) that are reported as simple values. ---maximum-throughput - Maximum number of jobs/flows to start in one second. (Default: Infinity) ---use-flows - Run flows instead of jobs. ---histogram-buckets [buckets...] - Define what buckets to collect from histograms. (Default: [ "+Inf", "10", "5", "2.5", "2.5", "1", "0.5", "0.25", "0.1", "0.05", "0.025", "0.01", "0.005" ]) - -Environment variables: - -WM_TOKEN - The token to use when talking to the API server. Preferred -over manual login. WM_WORKSPACE - The workspace to spawn scripts -from. - - - -This will run a simple benchmark against localhost (the default admin email + -password are set above), all execution is done in the "bench" workspace (as set -via `--workspace`). - -Metrics are exported to JSON will only include mean & stdev, histograms get one -entry for each bucket. CSV will include a full list of all values scraped. - -## NOOP jobs benchmark - -A specific benchmark creating a set of NOOP jobs all at once in windmill is also available. -in `benchmarks_noop.ts` - -You can build it locally with: -``` -deno install -A benchmarks_noop.ts -``` -and then -``` -benchmarks_noop -e admin@windmill.dev -p changeme --host YOUR_HOST +```bash +deno run -A benchmark_graphs.ts -c graphs_config.json ``` -By default it creates 10000 jobs in Windmill in a single batch, but this is parametrizable. \ No newline at end of file +Generates SVG graphs from `*_benchmark.json` data files. + +## CI + +The GitHub Actions workflow (`.github/workflows/benchmark.yml`) runs hourly with 1/4/8 worker configurations plus WAC benchmarks. Results are committed to the `benchmarks` branch. diff --git a/benchmarks/benchmark_graphs.ts b/benchmarks/benchmark_graphs.ts index 23b3b763f7..bfaad7dbb6 100644 --- a/benchmarks/benchmark_graphs.ts +++ b/benchmarks/benchmark_graphs.ts @@ -3,32 +3,20 @@ import { UpgradeCommand } from "https://deno.land/x/cliffy@v0.25.7/command/upgra import { DenoLandProvider } from "https://deno.land/x/cliffy@v0.25.7/command/upgrade/mod.ts"; import { drawGraph, drawGraphMulti } from "./graph.ts"; -import { VERSION } from "./lib.ts"; +import { VERSION, loadJsonConfig } from "./lib.ts"; -type GraphsConfig = [ - { - graph_title: string; - benchmarks: { - kind: string; - workers: number; - label: string; - }[]; - jobs: number; - } -]; +type GraphsConfig = { + graph_title: string; + benchmarks: { + kind: string; + workers: number; + label: string; + }[]; +}[]; async function main({ configPath }: { configPath: string }) { - async function getConfig(configPath: string): Promise { - if (configPath.startsWith("http")) { - const response = await fetch(configPath); - return await response.json(); - } else { - return JSON.parse(await Deno.readTextFile(configPath)); - } - } - try { - const config = await getConfig(configPath); + const config = await loadJsonConfig(configPath); for (const graphConfig of config || []) { const data: { @@ -81,7 +69,7 @@ async function main({ configPath }: { configPath: string }) { } await new Command() - .name("wmillbenchsuite") + .name("wmillbenchgraphs") .description("Create and save graphs from benchmark data.") .version(VERSION) .option("-c --config-path ", "The path of the config file", { diff --git a/benchmarks/benchmark_oneoff.ts b/benchmarks/benchmark_oneoff.ts index 0cd4c3483f..5f075d2034 100644 --- a/benchmarks/benchmark_oneoff.ts +++ b/benchmarks/benchmark_oneoff.ts @@ -10,7 +10,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"; -import { VERSION, createBenchScript, getFlowPayload, login } from "./lib.ts"; +import { VERSION, createBenchScript, createWacBenchScript, getFlowPayload, login, WAC_KINDS, STEPS_PER_WORKFLOW } from "./lib.ts"; async function verifyOutputs(uuids: string[], workspace: string) { console.log("Verifying outputs"); @@ -38,6 +38,8 @@ async function verifyOutputs(uuids: string[], workspace: string) { } export const NON_TEST_TAGS = ["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "dedicated_nativets", "flow"] + +const FLOW_COMPARISON_KINDS = ["flow_seq_2_bun", "flow_par_2_bun", "flow_seq_3_bun"]; export async function main({ host, email, @@ -151,6 +153,8 @@ export async function main({ ) ) { await createBenchScript(kind, workspace); + } else if (WAC_KINDS.includes(kind)) { + await createWacBenchScript(kind, workspace); } @@ -173,6 +177,20 @@ export async function main({ kind: "script", path: "f/benchmarks/" + kind, }); + } else if (WAC_KINDS.includes(kind)) { + // WAC v2 scripts are deployed as bun scripts, run via script path + nStepsFlow = STEPS_PER_WORKFLOW[kind] ?? 0; + body = JSON.stringify({ + kind: "script", + path: "f/benchmarks/" + kind, + }); + } else if (FLOW_COMPARISON_KINDS.includes(kind)) { + nStepsFlow = STEPS_PER_WORKFLOW[kind] ?? 0; + const payload = getFlowPayload(kind); + body = JSON.stringify({ + kind: "flow", + flow_value: payload.value, + }); } else if (["2steps", "bigscriptinflow"].includes(kind)) { nStepsFlow = kind == "2steps" ? 2 : 1; const payload = getFlowPayload(kind); @@ -182,7 +200,7 @@ export async function main({ }); } else if (kind.startsWith("flow:")) { console.log("Detected custom flow "); - let flow_path = kind.substr(5); + let flow_path = kind.substring(5); nStepsFlow = await getFlowStepCount(config.workspace_id, flow_path); console.log(`Total steps of flow including sub-flows: ${nStepsFlow}`); body = JSON.stringify({ @@ -193,7 +211,7 @@ export async function main({ console.log("Detected custom script"); body = JSON.stringify({ kind: "script", - path: kind.substr(7), + path: kind.substring(7), }); } else if (kind == "bigrawscript") { noVerify = true; @@ -281,6 +299,9 @@ export async function main({ let lastElapsed = 0; let lastCompletedJobs = 0; + // Timeout: 10 minutes for the polling loop to prevent hanging forever + // (e.g. if WAC suspend/resume fails or jobs get stuck) + const POLL_TIMEOUT_MS = 10 * 60 * 1000; let didStart = false; while (completedJobs < jobsSent) { const loopStart = Date.now(); @@ -292,6 +313,10 @@ export async function main({ } } else { const elapsed = start ? Date.now() - start : 0; + if (elapsed > POLL_TIMEOUT_MS) { + console.error(`\nTimeout: benchmark did not complete within ${POLL_TIMEOUT_MS / 1000}s (${completedJobs}/${jobsSent} completed)`); + break; + } completedJobs = await getCompletedJobsCount(NON_TEST_TAGS); if (nStepsFlow > 0) { completedJobs = Math.floor(completedJobs / (nStepsFlow + 1)); @@ -338,7 +363,9 @@ export async function main({ kind !== "nativets" && kind !== "dedicated_nativets" && !kind.startsWith("flow:") && - !kind.startsWith("script:") + !kind.startsWith("script:") && + !WAC_KINDS.includes(kind) && + !FLOW_COMPARISON_KINDS.includes(kind) ) { await verifyOutputs(uuids, config.workspace_id); } @@ -387,7 +414,7 @@ if (import.meta.main) { ) .option( "--kind ", - "Specifiy the benchmark kind among: deno, identity, python, go, bash, dedicated, bun, noop, 2steps, nativets, dedicated_nativets", + "Specifiy the benchmark kind among: deno, identity, python, go, bash, dedicated, bun, noop, 2steps, nativets, dedicated_nativets, wac_seq_2, wac_par_2, wac_seq_3, wac_inline_2, flow_seq_2_bun, flow_par_2_bun, flow_seq_3_bun", { required: true, } diff --git a/benchmarks/benchmark_suite.ts b/benchmarks/benchmark_suite.ts index f4840dda05..caba48aa72 100644 --- a/benchmarks/benchmark_suite.ts +++ b/benchmarks/benchmark_suite.ts @@ -4,7 +4,7 @@ import { DenoLandProvider } from "https://deno.land/x/cliffy@v0.25.7/command/upg import { main as runBenchmark } from "./benchmark_oneoff.ts"; -import { VERSION } from "./lib.ts"; +import { VERSION, loadJsonConfig } from "./lib.ts"; type Config = { kind: string; @@ -50,21 +50,12 @@ async function main({ workers: number; factor?: number; }) { - async function getConfig(configPath: string): Promise { - if (configPath.startsWith("http")) { - const response = await fetch(configPath); - return await response.json(); - } else { - return JSON.parse(await Deno.readTextFile(configPath)); - } - } - if (!Deno.args.includes("--no-warm-up")) { await warmUp(host, email, password, token, workspace); } try { - const config = await getConfig(configPath); + const config = await loadJsonConfig(configPath); for (const benchmark of config) { try { console.log( diff --git a/benchmarks/graphs_config.json b/benchmarks/graphs_config.json index 174990ecb2..8e37695d66 100644 --- a/benchmarks/graphs_config.json +++ b/benchmarks/graphs_config.json @@ -223,5 +223,75 @@ "label": "noop" } ] + }, + { + "graph_title": "WAC v2 sequential vs flow sequential (2 steps, bun)", + "benchmarks": [ + { + "kind": "wac_seq_2", + "workers": 1, + "label": "WAC v2 sequential" + }, + { + "kind": "flow_seq_2_bun", + "workers": 1, + "label": "Flow sequential" + } + ] + }, + { + "graph_title": "WAC v2 parallel vs flow parallel (2 steps, bun)", + "benchmarks": [ + { + "kind": "wac_par_2", + "workers": 1, + "label": "WAC v2 parallel" + }, + { + "kind": "flow_par_2_bun", + "workers": 1, + "label": "Flow parallel" + } + ] + }, + { + "graph_title": "WAC v2 sequential vs flow sequential (3 steps, bun)", + "benchmarks": [ + { + "kind": "wac_seq_3", + "workers": 1, + "label": "WAC v2 sequential" + }, + { + "kind": "flow_seq_3_bun", + "workers": 1, + "label": "Flow sequential" + } + ] + }, + { + "graph_title": "WAC v2 patterns comparison", + "benchmarks": [ + { + "kind": "wac_seq_2", + "workers": 1, + "label": "sequential 2-task" + }, + { + "kind": "wac_par_2", + "workers": 1, + "label": "parallel 2-task" + }, + { + "kind": "wac_seq_3", + "workers": 1, + "label": "sequential 3-task" + }, + { + "kind": "wac_inline_2", + "workers": 1, + "label": "inline 2-step" + } + ] } ] \ No newline at end of file diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 829bd04e08..ac0e20f064 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -132,6 +132,119 @@ export async function createBenchScript( } } +// WAC v2 benchmark script content patterns +const WAC_SCRIPTS: Record = { + wac_seq_2: [ + 'import { task, workflow } from "windmill-client";', + "const step_a = task(async () => { return 1; });", + "const step_b = task(async () => { return 2; });", + "export const main = workflow(async () => {", + " const a = await step_a();", + " const b = await step_b();", + " return { a, b };", + "});", + ].join("\n"), + + wac_par_2: [ + 'import { task, workflow } from "windmill-client";', + "const step_a = task(async () => { return 1; });", + "const step_b = task(async () => { return 2; });", + "export const main = workflow(async () => {", + " const [a, b] = await Promise.all([step_a(), step_b()]);", + " return { a, b };", + "});", + ].join("\n"), + + wac_seq_3: [ + 'import { task, workflow } from "windmill-client";', + "const step_a = task(async () => { return 1; });", + "const step_b = task(async () => { return 2; });", + "const step_c = task(async () => { return 3; });", + "export const main = workflow(async () => {", + " const a = await step_a();", + " const b = await step_b();", + " const c = await step_c();", + " return { a, b, c };", + "});", + ].join("\n"), + + wac_inline_2: [ + 'import { step, workflow } from "windmill-client";', + "export const main = workflow(async () => {", + ' const a = await step("a", () => 1);', + ' const b = await step("b", () => 2);', + " return { a, b };", + "});", + ].join("\n"), +}; + +export const WAC_KINDS = Object.keys(WAC_SCRIPTS); + +// Number of child jobs created per workflow instance (used to compute throughput) +// For task(): each task creates a child job. For step(): no child job. +// Total completed jobs per workflow = nSteps + 1 (children + parent) +export const STEPS_PER_WORKFLOW: Record = { + wac_seq_2: 2, + wac_par_2: 2, + wac_seq_3: 3, + wac_inline_2: 0, // inline steps don't create child jobs + flow_seq_2_bun: 2, + flow_par_2_bun: 2, + flow_seq_3_bun: 3, +}; + +export async function createWacBenchScript( + wacPattern: string, + workspace: string, +) { + const scriptContent = WAC_SCRIPTS[wacPattern]; + if (!scriptContent) { + throw new Error("Unknown WAC pattern: " + wacPattern); + } + + const path = `f/benchmarks/${wacPattern}`; + const exists = await windmill.ScriptService.existsScriptByPath({ + workspace, + path, + }); + + if (exists) { + await windmill.ScriptService.deleteScriptByPath({ + workspace, + path, + }); + } + + const hash = await windmill.ScriptService.createScript({ + workspace, + requestBody: { + path, + content: scriptContent, + summary: wacPattern + " WAC v2 benchmark", + description: "", + language: "bun" as api.NewScript.language, + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + properties: {}, + required: [], + type: "object", + }, + }, + }); + + await waitForDeployment(workspace, hash); + console.log("Created WAC v2 benchmark script at path", path); +} + +export async function loadJsonConfig(configPath: string): Promise { + if (configPath.startsWith("http")) { + const response = await fetch(configPath); + return await response.json(); + } else { + return JSON.parse(await Deno.readTextFile(configPath)); + } +} + export const getFlowPayload = (flowPattern: string): api.FlowPreview => { if (flowPattern == "branchone") { return { @@ -260,6 +373,113 @@ export const getFlowPayload = (flowPattern: string): api.FlowPreview => { ], }, }; + } else if (flowPattern == "flow_seq_2_bun") { + return { + path: "flow_seq_2_bun", + args: {}, + value: { + modules: [ + { + id: "a", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 1; }", + }, + }, + { + id: "b", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 2; }", + }, + }, + ], + }, + }; + } else if (flowPattern == "flow_par_2_bun") { + return { + path: "flow_par_2_bun", + args: {}, + value: { + modules: [ + { + id: "a", + value: { + type: "branchall", + parallel: true, + branches: [ + { + modules: [ + { + id: "b", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 1; }", + }, + }, + ], + }, + { + modules: [ + { + id: "c", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 2; }", + }, + }, + ], + }, + ], + }, + }, + ], + }, + }; + } else if (flowPattern == "flow_seq_3_bun") { + return { + path: "flow_seq_3_bun", + args: {}, + value: { + modules: [ + { + id: "a", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 1; }", + }, + }, + { + id: "b", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 2; }", + }, + }, + { + id: "c", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 3; }", + }, + }, + ], + }, + }; } else { return { path: "2steps", diff --git a/benchmarks/main.ts b/benchmarks/main.ts index 4d8d144cd4..1f681a9c03 100644 --- a/benchmarks/main.ts +++ b/benchmarks/main.ts @@ -264,24 +264,6 @@ export async function main({ ); const shutdown_start = Date.now(); - // let zombie_jobs = 0; - // let incorrect_results = 0; - // workers.forEach((worker, i) => { - // const l = (evt: MessageEvent) => { - // if (evt.data.type === "zombie_jobs") { - // zombie_jobs += evt.data.zombie_jobs; - // incorrect_results += evt.data.incorrect_results; - // worker.removeEventListener("message", l); - // workers = workers.filter((w) => w != worker); - // jobsSent[i] = evt.data.jobs_sent; - // worker.terminate(); - // } - // }; - // worker.addEventListener("message", l); - // worker.postMessage( - // Number.isSafeInteger(zombieTimeout) ? zombieTimeout : 90000 - // ); - // }); workers.forEach((worker, i) => { const l = (evt: MessageEvent) => { if (evt.data.type === "done") { @@ -327,8 +309,6 @@ export async function main({ console.log("time (s + tts):", time); console.log("throughput /s (jobs/time):", sum / time); - // console.log("zombie jobs: ", zombie_jobs); - // console.log("incorrect results: ", incorrect_results); console.log( "queue length:", ( diff --git a/benchmarks/suite_wac.json b/benchmarks/suite_wac.json new file mode 100644 index 0000000000..e677952761 --- /dev/null +++ b/benchmarks/suite_wac.json @@ -0,0 +1,30 @@ +[ + { + "kind": "wac_seq_2", + "jobs": 250 + }, + { + "kind": "wac_par_2", + "jobs": 250 + }, + { + "kind": "wac_seq_3", + "jobs": 200 + }, + { + "kind": "wac_inline_2", + "jobs": 500 + }, + { + "kind": "flow_seq_2_bun", + "jobs": 250 + }, + { + "kind": "flow_par_2_bun", + "jobs": 250 + }, + { + "kind": "flow_seq_3_bun", + "jobs": 200 + } +] diff --git a/benchmarks/worker.ts b/benchmarks/worker.ts index cd56d0fb45..ae06fa9779 100644 --- a/benchmarks/worker.ts +++ b/benchmarks/worker.ts @@ -139,96 +139,6 @@ while (cont) { clearInterval(updateStatusInterval); -// const end_time = Date.now() + complete_timeout; - -// let incorrect_results = 0; -// const enc = (s: string) => new TextEncoder().encode(s); - -// let last_queue_length = await getQueueCount(); -// console.log(`waiting for ${last_queue_length} jobs to complete...`); - -// while ( -// outstanding.length > 0 && -// last_queue_length > 0 && -// Date.now() < end_time -// ) { -// try { -// if (!config.hideProgress) { -// await Deno.stdout.write( -// enc( -// "\rwaiting for jobs to complete: outstanding " + -// outstanding.length + -// " - queue" + -// last_queue_length + -// "\n" -// ) -// ); -// } -// last_queue_length = await getQueueCount(); - -// const uuid = outstanding.shift()!; - -// let r: Job; -// try { -// r = await windmill.JobService.getJob({ -// workspace: config.workspace_id, -// id: uuid, -// }); -// } catch (e) { -// console.log("job not found: " + uuid + " " + e.message); -// continue; -// } -// if (r.type == "QueuedJob") { -// outstanding.push(uuid); - -// if (!config.hideProgress) { -// await Deno.stdout.write( -// enc(`uuid: ${uuid}, queue length: ${last_queue_length}\r`) -// ); -// } -// } else { -// r = r as api.CompletedJob; -// try { -// if ( -// ![ -// "httpversion", -// "identity", -// "httpslow", -// "noop", -// "dedicated", -// ].includes(config.scriptPattern) && -// r.result != uuid -// ) { -// console.log( -// "job did not return correct UUID: " + -// r.result + -// " != " + -// uuid + -// "job: \n" + -// JSON.stringify(r, null, 2) -// ); -// incorrect_results++; -// } else { -// // console.log(r.result); -// } -// } catch (e) { -// console.log("error during wait: ", e); -// outstanding.push(uuid); -// } -// } -// } catch (e) { -// console.log("error while waiting for outstanding jobs, sleeing: ", e); -// await sleep(0.5); -// } -// } - -// self.postMessage({ -// type: "zombie_jobs", -// zombie_jobs: outstanding.length, -// incorrect_results, -// jobs_sent: total_spawned, -// }); - self.postMessage({ type: "done", jobs_sent: total_spawned, From 0389d9601cd540bfcad2270ed608193c3fa5a297 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 09:55:04 +0000 Subject: [PATCH 065/153] chore: upgrade axum 0.7 to 0.8 (#8539) * chore: upgrade axum 0.7 to 0.8 and related dependencies Co-Authored-By: Claude Opus 4.6 (1M context) * test: add route reachability tests for ~80 previously untested endpoints Co-Authored-By: Claude Opus 4.6 (1M context) * fix: switch feature-gated trigger handlers from axum::async_trait to async_trait crate Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update new trash routes to axum 0.8 path syntax Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to latest EE commit Co-Authored-By: Claude Opus 4.6 (1M context) * test: upgrade route tests to assert 2xx responses with proper data setup Co-Authored-By: Claude Opus 4.6 (1M context) * test: restore npm_proxy and ai_routes tests using local echo servers Co-Authored-By: Claude Opus 4.6 (1M context) * fix: gate workspace fork test behind enterprise feature flag Co-Authored-By: Claude Opus 4.6 (1M context) * test: add ~40 more endpoint tests (jobs authed, health, favorites, ACLs, reachability) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review findings from axum 0.8 upgrade - Use cookie value_trimmed() instead of value() for cookie 0.18 compat - Update comments still referencing old :workspace_id syntax Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 61ae055ea31481f1899953e9d5f65566b8c707b1 This commit updates the EE repository reference after PR #486 was merged in windmill-ee-private. Previous ee-repo-ref: 0059d175a6fdddf52998b183bf91059b224704ac New ee-repo-ref: 61ae055ea31481f1899953e9d5f65566b8c707b1 Automated by sync-ee-ref workflow. * test: add test for new get_imports endpoint Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove unused import in raw_apps test Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/Cargo.lock | 265 +++++++++------ backend/Cargo.toml | 22 +- backend/ee-repo-ref.txt | 2 +- backend/windmill-api-auth/src/auth.rs | 9 +- backend/windmill-api-auth/src/lib.rs | 26 +- backend/windmill-api-configs/src/lib.rs | 6 +- .../src/lib.rs | 4 +- backend/windmill-api-flows/src/flows.rs | 32 +- .../windmill-api-groups/src/folder_history.rs | 2 +- backend/windmill-api-groups/src/folders.rs | 16 +- .../windmill-api-groups/src/granular_acls.rs | 6 +- backend/windmill-api-groups/src/groups.rs | 22 +- backend/windmill-api-inputs/src/lib.rs | 4 +- .../windmill-api-integration-tests/Cargo.toml | 1 + .../tests/ai_routes.rs | 106 ++++++ .../tests/audit.rs | 35 ++ .../tests/capture_unauthed.rs | 83 +++++ .../tests/concurrency_groups.rs | 48 +++ .../tests/favorites.rs | 72 ++++ .../tests/folder_history.rs | 51 +++ .../tests/granular_acls.rs | 54 +++ .../tests/group_history.rs | 32 ++ .../tests/health.rs | 42 +++ .../tests/inputs.rs | 76 +++++ .../tests/job_metrics.rs | 59 ++++ .../tests/jobs_authed.rs | 308 ++++++++++++++++++ .../tests/jobs_unauthed.rs | 250 ++++++++++++++ .../tests/npm_proxy.rs | 85 +++++ .../tests/raw_apps.rs | 33 ++ .../tests/service_logs.rs | 36 ++ .../tests/settings.rs | 116 +++++++ .../tests/trash.rs | 41 +++ .../tests/workspace_deps.rs | 39 +++ .../tests/workspaces.rs | 117 ++++--- .../src/concurrency_groups.rs | 4 +- backend/windmill-api-jobs/src/job_metrics.rs | 6 +- backend/windmill-api-jobs/src/types.rs | 8 +- backend/windmill-api-npm-proxy/src/lib.rs | 8 +- backend/windmill-api-schedule/src/lib.rs | 10 +- backend/windmill-api-scripts/src/scripts.rs | 46 +-- backend/windmill-api-settings/src/lib.rs | 6 +- backend/windmill-api-users/src/users.rs | 26 +- .../windmill-api-workspaces/src/workspaces.rs | 14 +- backend/windmill-api/src/ai.rs | 4 +- backend/windmill-api/src/apps.rs | 54 +-- backend/windmill-api/src/args.rs | 3 +- backend/windmill-api/src/audit.rs | 2 +- backend/windmill-api/src/capture.rs | 18 +- backend/windmill-api/src/drafts.rs | 2 +- backend/windmill-api/src/flows.rs | 2 +- backend/windmill-api/src/google.rs | 54 +-- backend/windmill-api/src/group_history.rs | 2 +- backend/windmill-api/src/jobs.rs | 108 +++--- backend/windmill-api/src/lib.rs | 48 +-- backend/windmill-api/src/raw_apps.rs | 2 +- backend/windmill-api/src/resources.rs | 2 +- backend/windmill-api/src/scim_oss.rs | 6 +- backend/windmill-api/src/scripts.rs | 2 +- backend/windmill-api/src/service_logs.rs | 2 +- backend/windmill-api/src/trash.rs | 6 +- backend/windmill-api/src/triggers/handler.rs | 4 +- .../windmill-api/src/triggers/http/handler.rs | 2 +- .../src/triggers/http/http_trigger_args.rs | 3 +- backend/windmill-api/src/users.rs | 4 +- .../src/workspace_dependencies.rs | 6 +- backend/windmill-api/src/workspaces.rs | 2 +- .../windmill-native-triggers/src/handler.rs | 6 +- .../src/workspace_integrations.rs | 14 +- backend/windmill-oauth/src/lib.rs | 2 +- backend/windmill-object-store/src/lib.rs | 4 +- backend/windmill-store/src/resources.rs | 28 +- backend/windmill-store/src/variables.rs | 10 +- backend/windmill-test-utils/Cargo.toml | 1 + backend/windmill-test-utils/src/lib.rs | 2 +- .../windmill-trigger-email/src/handler_oss.rs | 2 +- .../windmill-trigger-gcp/src/handler_oss.rs | 2 +- backend/windmill-trigger-http/src/handler.rs | 3 +- .../windmill-trigger-kafka/src/handler_oss.rs | 2 +- backend/windmill-trigger-mqtt/src/handler.rs | 2 +- .../windmill-trigger-nats/src/handler_oss.rs | 2 +- .../windmill-trigger-postgres/src/handler.rs | 30 +- .../windmill-trigger-sqs/src/handler_oss.rs | 2 +- .../windmill-trigger-websocket/src/handler.rs | 2 +- backend/windmill-trigger/src/handler.rs | 10 +- 84 files changed, 2176 insertions(+), 514 deletions(-) create mode 100644 backend/windmill-api-integration-tests/tests/ai_routes.rs create mode 100644 backend/windmill-api-integration-tests/tests/audit.rs create mode 100644 backend/windmill-api-integration-tests/tests/capture_unauthed.rs create mode 100644 backend/windmill-api-integration-tests/tests/concurrency_groups.rs create mode 100644 backend/windmill-api-integration-tests/tests/favorites.rs create mode 100644 backend/windmill-api-integration-tests/tests/folder_history.rs create mode 100644 backend/windmill-api-integration-tests/tests/granular_acls.rs create mode 100644 backend/windmill-api-integration-tests/tests/group_history.rs create mode 100644 backend/windmill-api-integration-tests/tests/health.rs create mode 100644 backend/windmill-api-integration-tests/tests/inputs.rs create mode 100644 backend/windmill-api-integration-tests/tests/job_metrics.rs create mode 100644 backend/windmill-api-integration-tests/tests/jobs_authed.rs create mode 100644 backend/windmill-api-integration-tests/tests/jobs_unauthed.rs create mode 100644 backend/windmill-api-integration-tests/tests/npm_proxy.rs create mode 100644 backend/windmill-api-integration-tests/tests/raw_apps.rs create mode 100644 backend/windmill-api-integration-tests/tests/service_logs.rs create mode 100644 backend/windmill-api-integration-tests/tests/settings.rs create mode 100644 backend/windmill-api-integration-tests/tests/trash.rs create mode 100644 backend/windmill-api-integration-tests/tests/workspace_deps.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 031344cc61..df09b225b5 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1363,32 +1363,23 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core 0.4.5", - "axum-macros", "bytes", "futures-util", "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", - "hyper-util", "itoa", "matchit 0.7.3", "memchr", "mime", - "multer", "percent-encoding", "pin-project-lite", "rustversion", "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", "sync_wrapper", - "tokio", "tower 0.5.3", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1398,6 +1389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core 0.5.6", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -1410,6 +1402,7 @@ dependencies = [ "matchit 0.8.4", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -1443,7 +1436,6 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1467,9 +1459,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", @@ -2539,16 +2531,6 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" -[[package]] -name = "cookie" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" -dependencies = [ - "time", - "version_check", -] - [[package]] name = "cookie" version = "0.18.1" @@ -4552,9 +4534,9 @@ dependencies = [ "log", "once_cell", "opentelemetry 0.27.1", - "opentelemetry-http", - "opentelemetry-otlp", - "opentelemetry-semantic-conventions", + "opentelemetry-http 0.27.0", + "opentelemetry-otlp 0.27.0", + "opentelemetry-semantic-conventions 0.27.0", "opentelemetry_sdk 0.27.1", "pin-project", "serde", @@ -6333,7 +6315,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-retry2", - "tonic", + "tonic 0.12.3", "tower 0.4.13", "tracing", ] @@ -6346,7 +6328,7 @@ checksum = "886aa8ec755382a1fdf4651f6e6ec01f2f3bf49f2cb0f068b9a74cafd574a715" dependencies = [ "prost", "prost-types", - "tonic", + "tonic 0.12.3", ] [[package]] @@ -9551,9 +9533,9 @@ dependencies = [ [[package]] name = "opentelemetry" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e87237e2775f74896f9ad219d26a2081751187eb7c9f5c58dde20a23b95d16c" +checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" dependencies = [ "futures-core", "futures-sink", @@ -9565,11 +9547,11 @@ dependencies = [ [[package]] name = "opentelemetry-appender-tracing" -version = "0.27.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5feffc321035ad94088a7e5333abb4d84a8726e54a802e736ce9dd7237e85b" +checksum = "e68f63eca5fad47e570e00e893094fc17be959c80c79a7d6ec1abdd5ae6ffc16" dependencies = [ - "opentelemetry 0.27.1", + "opentelemetry 0.30.0", "tracing", "tracing-core", "tracing-subscriber", @@ -9587,6 +9569,19 @@ dependencies = [ "opentelemetry 0.27.1", ] +[[package]] +name = "opentelemetry-http" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.0", + "opentelemetry 0.30.0", + "reqwest 0.12.28", +] + [[package]] name = "opentelemetry-otlp" version = "0.27.0" @@ -9597,14 +9592,33 @@ dependencies = [ "futures-core", "http 1.4.0", "opentelemetry 0.27.1", - "opentelemetry-http", + "opentelemetry-http 0.27.0", "opentelemetry-proto 0.27.0", "opentelemetry_sdk 0.27.1", "prost", "serde_json", "thiserror 1.0.69", "tokio", - "tonic", + "tonic 0.12.3", + "tracing", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" +dependencies = [ + "http 1.4.0", + "opentelemetry 0.30.0", + "opentelemetry-http 0.30.0", + "opentelemetry-proto 0.30.0", + "opentelemetry_sdk 0.30.0", + "prost", + "reqwest 0.12.28", + "thiserror 2.0.18", + "tokio", + "tonic 0.13.1", "tracing", ] @@ -9619,23 +9633,22 @@ dependencies = [ "opentelemetry_sdk 0.27.1", "prost", "serde", - "tonic", + "tonic 0.12.3", ] [[package]] name = "opentelemetry-proto" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c40da242381435e18570d5b9d50aca2a4f4f4d8e146231adb4e7768023309b3" +checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc" dependencies = [ "base64 0.22.1", "hex", - "opentelemetry 0.29.1", - "opentelemetry_sdk 0.29.0", + "opentelemetry 0.30.0", + "opentelemetry_sdk 0.30.0", "prost", "serde", - "tonic", - "tracing", + "tonic 0.13.1", ] [[package]] @@ -9644,6 +9657,12 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc1b6902ff63b32ef6c489e8048c5e253e2e4a803ea3ea7e783914536eb15c52" +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d059a296a47436748557a353c5e6c5705b9470ef6c95cfc52c21a8814ddac2" + [[package]] name = "opentelemetry_sdk" version = "0.27.1" @@ -9660,26 +9679,25 @@ dependencies = [ "rand 0.8.5", "serde_json", "thiserror 1.0.69", - "tokio", - "tokio-stream", "tracing", ] [[package]] name = "opentelemetry_sdk" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afdefb21d1d47394abc1ba6c57363ab141be19e27cc70d0e422b7f303e4d290b" +checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b" dependencies = [ "futures-channel", "futures-executor", "futures-util", - "glob", - "opentelemetry 0.29.1", + "opentelemetry 0.30.0", "percent-encoding", "rand 0.9.0", "serde_json", "thiserror 2.0.18", + "tokio", + "tokio-stream", ] [[package]] @@ -11124,6 +11142,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.13", @@ -14516,7 +14535,6 @@ dependencies = [ "percent-encoding", "pin-project", "prost", - "rustls-native-certs 0.8.3", "rustls-pemfile 2.2.0", "socket2 0.5.10", "tokio", @@ -14529,6 +14547,37 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "tonic" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +dependencies = [ + "async-trait", + "axum 0.8.4", + "base64 0.22.1", + "bytes", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "rustls-native-certs 0.8.3", + "socket2 0.5.10", + "tokio", + "tokio-rustls 0.26.4", + "tokio-stream", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.4.13" @@ -14557,7 +14606,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.11.1", "pin-project-lite", + "slab", "sync_wrapper", "tokio", "tokio-util", @@ -14568,13 +14619,12 @@ dependencies = [ [[package]] name = "tower-cookies" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" dependencies = [ - "async-trait", - "axum-core 0.4.5", - "cookie 0.18.1", + "axum-core 0.5.6", + "cookie", "futures-util", "http 1.4.0", "parking_lot", @@ -14689,14 +14739,14 @@ dependencies = [ [[package]] name = "tracing-opentelemetry" -version = "0.28.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a971f6058498b5c0f1affa23e7ea202057a7301dbff68e968b2d578bcbd053" +checksum = "ddcf5959f39507d0d04d6413119c04f33b623f4f951ebcbdddddfad2d0623a9c" dependencies = [ "js-sys", "once_cell", - "opentelemetry 0.27.1", - "opentelemetry_sdk 0.27.1", + "opentelemetry 0.30.0", + "opentelemetry_sdk 0.30.0", "smallvec", "tracing", "tracing-core", @@ -15768,7 +15818,7 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-sqs", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "chrono", "constant_time_eq 0.3.1", @@ -15839,7 +15889,7 @@ dependencies = [ name = "windmill-alerting" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "serde", "serde_json", @@ -15866,14 +15916,14 @@ dependencies = [ "aws-sdk-config", "aws-sigv4", "aws-smithy-types", - "axum 0.7.9", + "axum 0.8.4", "base32", "base64 0.22.1", "bytes", "chrono", "chrono-tz", "const_format", - "cookie 0.17.0", + "cookie", "cron", "dashmap 6.1.0", "datafusion", @@ -15993,7 +16043,7 @@ dependencies = [ name = "windmill-api-agent-workers" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "http 1.4.0", "hyper 1.8.1", @@ -16016,7 +16066,7 @@ dependencies = [ name = "windmill-api-assets" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "serde", "serde_json", @@ -16030,7 +16080,7 @@ name = "windmill-api-auth" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "chrono", "http 1.4.0", "itertools 0.14.0", @@ -16065,7 +16115,7 @@ dependencies = [ name = "windmill-api-configs" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "itertools 0.14.0", "serde", @@ -16082,7 +16132,7 @@ dependencies = [ name = "windmill-api-debug" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "chrono", "ed25519-dalek", @@ -16106,7 +16156,7 @@ name = "windmill-api-embeddings" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "candle-core", "candle-nn", "candle-transformers", @@ -16128,7 +16178,7 @@ dependencies = [ name = "windmill-api-flow-conversations" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "serde", "sql-builder", @@ -16144,7 +16194,7 @@ dependencies = [ name = "windmill-api-flows" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "hyper 1.8.1", "serde", @@ -16164,7 +16214,7 @@ dependencies = [ name = "windmill-api-groups" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "lazy_static", "regex", @@ -16184,7 +16234,7 @@ dependencies = [ name = "windmill-api-inputs" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "serde", "serde_json", @@ -16228,7 +16278,7 @@ name = "windmill-api-jobs" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "chrono", "http 1.4.0", @@ -16252,7 +16302,7 @@ dependencies = [ name = "windmill-api-npm-proxy" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "flate2", "reqwest 0.13.1", "serde", @@ -16271,7 +16321,7 @@ name = "windmill-api-openapi" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "http 1.4.0", "indexmap 2.12.0", "itertools 0.14.0", @@ -16292,7 +16342,7 @@ dependencies = [ name = "windmill-api-schedule" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "chrono-tz", "serde", @@ -16312,7 +16362,7 @@ dependencies = [ name = "windmill-api-scripts" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "futures", "http 1.4.0", @@ -16343,7 +16393,7 @@ name = "windmill-api-settings" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "bytes", "chrono", @@ -16382,7 +16432,7 @@ name = "windmill-api-users" version = "1.666.0" dependencies = [ "argon2", - "axum 0.7.9", + "axum 0.8.4", "chrono", "http 1.4.0", "hyper 1.8.1", @@ -16405,7 +16455,7 @@ dependencies = [ name = "windmill-api-workers" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "serde", "serde_json", @@ -16419,7 +16469,7 @@ dependencies = [ name = "windmill-api-workspaces" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "hex", "http 1.4.0", @@ -16466,7 +16516,7 @@ name = "windmill-autoscaling" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "k8s-openapi", "kube", "serde", @@ -16497,7 +16547,7 @@ dependencies = [ "aws-sdk-sts", "aws-smithy-types", "aws-smithy-types-convert", - "axum 0.7.9", + "axum 0.8.4", "backon", "base64 0.22.1", "bitflags 2.9.4", @@ -16528,11 +16578,11 @@ dependencies = [ "native-tls", "once_cell", "openidconnect", - "opentelemetry 0.27.1", + "opentelemetry 0.30.0", "opentelemetry-appender-tracing", - "opentelemetry-otlp", - "opentelemetry-semantic-conventions", - "opentelemetry_sdk 0.27.1", + "opentelemetry-otlp 0.30.0", + "opentelemetry-semantic-conventions 0.30.0", + "opentelemetry_sdk 0.30.0", "pep440_rs", "phf 0.11.3", "pin-project-lite", @@ -16565,7 +16615,7 @@ dependencies = [ "tokio-postgres 0.7.13", "tokio-stream", "tokio-util", - "tonic", + "tonic 0.13.1", "tracing", "tracing-appender", "tracing-opentelemetry", @@ -16699,7 +16749,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "backon", "base64 0.22.1", "chrono", @@ -16730,7 +16780,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-oauth2", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "chrono", "hex", @@ -16759,7 +16809,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-sts", "aws-smithy-types-convert", - "axum 0.7.9", + "axum 0.8.4", "bytes", "chrono", "datafusion", @@ -17059,7 +17109,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-recursion", - "axum 0.7.9", + "axum 0.8.4", "backon", "chrono", "chrono-tz", @@ -17145,7 +17195,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-recursion", - "axum 0.7.9", + "axum 0.8.4", "chrono", "futures", "http 1.4.0", @@ -17173,7 +17223,8 @@ name = "windmill-test-utils" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "async-trait", + "axum 0.8.4", "chrono", "futures", "serde", @@ -17197,7 +17248,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "chrono", "http 1.4.0", "hyper 1.8.1", @@ -17230,7 +17281,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "lazy_static", "regex", @@ -17250,7 +17301,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "bytes", "chrono", @@ -17268,7 +17319,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", - "tonic", + "tonic 0.13.1", "tower-http", "tracing", "windmill-api-auth", @@ -17284,7 +17335,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "constant_time_eq 0.3.1", "futures", @@ -17319,7 +17370,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "itertools 0.14.0", "rdkafka", @@ -17342,7 +17393,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "bytes", "itertools 0.14.0", @@ -17367,7 +17418,7 @@ dependencies = [ "anyhow", "async-nats", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "itertools 0.14.0", "nkeys", @@ -17390,7 +17441,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "byteorder", "bytes", "chrono", @@ -17430,7 +17481,7 @@ dependencies = [ "aws-sdk-sqs", "aws-sdk-sts", "aws-smithy-types", - "axum 0.7.9", + "axum 0.8.4", "backon", "chrono", "itertools 0.14.0", @@ -17453,7 +17504,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "futures", "http 1.4.0", "itertools 0.14.0", @@ -17502,7 +17553,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-bedrockruntime", "aws-smithy-types", - "axum 0.7.9", + "axum 0.8.4", "backon", "base64 0.22.1", "bit-vec 0.6.3", @@ -17535,8 +17586,8 @@ dependencies = [ "native-tls", "nix 0.27.1", "once_cell", - "opentelemetry 0.27.1", - "opentelemetry-proto 0.29.0", + "opentelemetry 0.30.0", + "opentelemetry-proto 0.30.0", "oracle", "pem 3.0.6", "pep440_rs", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ee1c26c9f7..b8e4d3d593 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -362,7 +362,7 @@ reqwest-middleware = { version = "^0", features = ["json"] } bitflags = "2.9.4" memchr = "2.7.4" -axum = { version = "^0.7", features = ["multipart", "macros"] } +axum = { version = "^0.8", features = ["multipart", "macros"] } headers = "^0" hyper = { version = "^1", features = ["full"] } hyper-tls = "^0.6" @@ -371,7 +371,7 @@ tokio = { version = "=1.46.1", features = ["full", "tracing", "time"] } tokio-stream = { version = "0.1.17" } tower = "^0" tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] } -tower-cookies = "^0.10" +tower-cookies = "^0.11" #stuck because of swc for now serde = "=1.0.220" serde_json = { version = "^1", features = ["preserve_order", "raw_value"] } @@ -386,7 +386,7 @@ tracing = "^0" tracing-subscriber = { version = "^0", features = ["env-filter", "json"] } tracing-appender = "^0" prometheus = { version = "^0", default-features = false } -cookie = { version = "0.17.0" } +cookie = { version = "0.18.0" } phf = { version = "0.11", features = ["macros"] } rust-embed = { version = "^6", features = ["interpolate-folder-path"] } mime_guess = "^2" @@ -566,18 +566,18 @@ flate2 = "^1" http = "^1" async-stream = "^0" -opentelemetry = "0.27.0" -tracing-opentelemetry = "0.28.0" -opentelemetry_sdk = { version = "0.27.1", features = ["rt-tokio"] } -opentelemetry-otlp = { version = "0.27.0", features = ["grpc-tonic", "tls"] } -opentelemetry-appender-tracing = "0.27.0" -opentelemetry-semantic-conventions = { version = "0.27.0", features = ["semconv_experimental"] } -opentelemetry-proto = { version = "0.29.0", features = ["with-serde", "gen-tonic"] } +opentelemetry = "0.30.0" +tracing-opentelemetry = "0.31.0" +opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.30.0", features = ["grpc-tonic", "tls"] } +opentelemetry-appender-tracing = "0.30.0" +opentelemetry-semantic-conventions = { version = "0.30.0", features = ["semconv_experimental"] } +opentelemetry-proto = { version = "0.30.0", features = ["with-serde", "gen-tonic"] } prost = "0.13" bollard = "0.18.1" -tonic = { version = "=0.12.3", features = ["tls-native-roots"] } +tonic = { version = "^0.13", features = ["tls-native-roots"] } byteorder = "1.5.0" tikv-jemallocator = { version = "0.5" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 840129b249..3915abda7e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -01688af32ccd48a39f993043c1ce8f337b5c9eff \ No newline at end of file +61ae055ea31481f1899953e9d5f65566b8c707b1 diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 8e3a4c7822..f10ae321b9 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -1,7 +1,6 @@ #[cfg(feature = "enterprise")] use crate::ee_oss::ExternalJwks; use axum::{ - async_trait, extract::{FromRequestParts, OriginalUri, Query}, Extension, Json, }; @@ -451,7 +450,11 @@ pub(crate) async fn extract_token(parts: &mut Parts, state: &S) None => Extension::::from_request_parts(parts, state) .await .ok() - .and_then(|cookies| cookies.get(COOKIE_NAME).map(|c| c.value().to_owned())), + .and_then(|cookies| { + cookies + .get(COOKIE_NAME) + .map(|c| c.value_trimmed().to_owned()) + }), }; #[derive(Deserialize)] @@ -504,7 +507,6 @@ impl BruteForceCounter { } } -#[async_trait] impl FromRequestParts for Tokened where S: Send + Sync, @@ -535,7 +537,6 @@ where } } -#[async_trait] impl FromRequestParts for OptTokened where S: Send + Sync, diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index aceef77e01..4e230c019b 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -12,8 +12,7 @@ pub mod ee; pub mod ee_oss; pub mod scopes; -use axum::async_trait; -use axum::extract::FromRequestParts; +use axum::extract::{FromRequestParts, OptionalFromRequestParts}; use http::request::Parts; use windmill_audit::audit_oss::AuditAuthorable; @@ -345,7 +344,6 @@ pub async fn maybe_refresh_folders( // ------------ FromRequestParts impls (direct call to auth module) ------------ -#[async_trait] impl FromRequestParts for ApiAuthed where S: Send + Sync, @@ -361,7 +359,24 @@ where } } -#[async_trait] +impl OptionalFromRequestParts for ApiAuthed +where + S: Send + Sync, +{ + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> std::result::Result, Self::Rejection> { + Ok( + >::from_request_parts(parts, state) + .await + .ok(), + ) + } +} + impl FromRequestParts for OptJobAuthed where S: Send + Sync, @@ -397,7 +412,6 @@ fn empty_parts() -> Parts { #[derive(Clone, Debug)] pub struct OptAuthed(pub Option); -#[async_trait] impl FromRequestParts for OptAuthed where S: Send + Sync, @@ -408,7 +422,7 @@ where parts: &mut Parts, state: &S, ) -> std::result::Result { - ApiAuthed::from_request_parts(parts, state) + >::from_request_parts(parts, state) .await .map(|authed| Self(Some(authed))) .or_else(|_| Ok(Self(None))) diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs index a05f2dbaa8..8485de5e95 100644 --- a/backend/windmill-api-configs/src/lib.rs +++ b/backend/windmill-api-configs/src/lib.rs @@ -28,11 +28,11 @@ use windmill_api_auth::{require_devops_role, ApiAuthed}; pub fn global_service() -> Router { Router::new() .route("/list_worker_groups", get(list_worker_groups)) - .route("/update/:name", post(update_config).delete(delete_config)) - .route("/get/:name", get(get_config)) + .route("/update/{name}", post(update_config).delete(delete_config)) + .route("/get/{name}", get(get_config)) .route("/list", get(list_configs)) .route( - "/list_autoscaling_events/:worker_group", + "/list_autoscaling_events/{worker_group}", get(list_autoscaling_events), ) .route( diff --git a/backend/windmill-api-flow-conversations/src/lib.rs b/backend/windmill-api-flow-conversations/src/lib.rs index bc37c9863d..70c96d1405 100644 --- a/backend/windmill-api-flow-conversations/src/lib.rs +++ b/backend/windmill-api-flow-conversations/src/lib.rs @@ -21,8 +21,8 @@ use windmill_common::{ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_conversations)) - .route("/delete/:conversation_id", delete(delete_conversation)) - .route("/:conversation_id/messages", get(list_messages)) + .route("/delete/{conversation_id}", delete(delete_conversation)) + .route("/{conversation_id}/messages", get(list_messages)) } #[derive(Serialize, FromRow, Debug)] diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index e5a78da5b3..89fd9629b7 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -61,26 +61,26 @@ pub fn workspaced_service() -> Router { .route("/list", get(list_flows)) .route("/list_search", get(list_search_flows)) .route("/create", post(create_flow)) - .route("/update/*path", post(update_flow)) - .route("/archive/*path", post(archive_flow_by_path)) - .route("/delete/*path", delete(delete_flow_by_path)) - .route("/list_tokens/*path", get(list_tokens)) - .route("/get/*path", get(get_flow_by_path)) - .route("/deployment_status/p/*path", get(get_deployment_status)) - .route("/get/draft/*path", get(get_flow_by_path_w_draft)) - .route("/exists/*path", get(exists_flow_by_path)) + .route("/update/{*path}", post(update_flow)) + .route("/archive/{*path}", post(archive_flow_by_path)) + .route("/delete/{*path}", delete(delete_flow_by_path)) + .route("/list_tokens/{*path}", get(list_tokens)) + .route("/get/{*path}", get(get_flow_by_path)) + .route("/deployment_status/p/{*path}", get(get_deployment_status)) + .route("/get/draft/{*path}", get(get_flow_by_path_w_draft)) + .route("/exists/{*path}", get(exists_flow_by_path)) .route("/list_paths", get(list_paths)) - .route("/history/p/*path", get(get_flow_history)) - .route("/get_latest_version/*path", get(get_latest_version)) + .route("/history/p/{*path}", get(get_flow_history)) + .route("/get_latest_version/{*path}", get(get_latest_version)) .route( - "/list_paths_from_workspace_runnable/:runnable_kind/*path", + "/list_paths_from_workspace_runnable/{runnable_kind}/{*path}", get(list_paths_from_workspace_runnable), ) - .route("/history_update/v/:version", post(update_flow_history)) - .route("/get/v/:version", get(get_flow_version_by_id)) - .route("/get/v/:version/p/*path", get(get_flow_version)) + .route("/history_update/v/{version}", post(update_flow_history)) + .route("/get/v/{version}", get(get_flow_version_by_id)) + .route("/get/v/{version}/p/{*path}", get(get_flow_version)) .route( - "/toggle_workspace_error_handler/*path", + "/toggle_workspace_error_handler/{*path}", post(toggle_workspace_error_handler), ) } @@ -88,7 +88,7 @@ pub fn workspaced_service() -> Router { pub fn global_service() -> Router { Router::new() .route("/hub/list", get(list_hub_flows)) - .route("/hub/get/:id", get(get_hub_flow_by_id)) + .route("/hub/get/{id}", get(get_hub_flow_by_id)) } #[derive(Serialize, FromRow)] diff --git a/backend/windmill-api-groups/src/folder_history.rs b/backend/windmill-api-groups/src/folder_history.rs index b11f328e3d..8ce1086dbf 100644 --- a/backend/windmill-api-groups/src/folder_history.rs +++ b/backend/windmill-api-groups/src/folder_history.rs @@ -22,7 +22,7 @@ use serde::Serialize; use sqlx::FromRow; pub fn workspaced_service() -> Router { - Router::new().route("/get/:name", get(get_folder_permission_history)) + Router::new().route("/get/{name}", get(get_folder_permission_history)) } #[derive(Serialize, FromRow)] diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index 89a64621cc..ab1541adc8 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -40,14 +40,14 @@ pub fn workspaced_service() -> Router { .route("/list", get(list_folders)) .route("/listnames", get(list_foldernames)) .route("/create", post(create_folder)) - .route("/get/:name", get(get_folder)) - .route("/exists/:name", get(exists_folder)) - .route("/update/:name", post(update_folder)) - .route("/getusage/:name", get(get_folder_usage)) - .route("/delete/:name", delete(delete_folder)) - .route("/addowner/:name", post(add_owner)) - .route("/removeowner/:name", post(remove_owner)) - .route("/is_owner/*path", get(is_owner_api)) + .route("/get/{name}", get(get_folder)) + .route("/exists/{name}", get(exists_folder)) + .route("/update/{name}", post(update_folder)) + .route("/getusage/{name}", get(get_folder_usage)) + .route("/delete/{name}", delete(delete_folder)) + .route("/addowner/{name}", post(add_owner)) + .route("/removeowner/{name}", post(remove_owner)) + .route("/is_owner/{*path}", get(is_owner_api)) } #[derive(FromRow, Serialize, Deserialize, Clone)] diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index da9267419d..d7ea8418f9 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -48,9 +48,9 @@ const KINDS: [&str; 19] = [ pub fn workspaced_service() -> Router { Router::new() - .route("/get/*path", get(get_granular_acls)) - .route("/add/*path", post(add_granular_acl)) - .route("/remove/*path", post(remove_granular_acl)) + .route("/get/{*path}", get(get_granular_acls)) + .route("/add/{*path}", post(add_granular_acl)) + .route("/remove/{*path}", post(remove_granular_acl)) } #[derive(Serialize, Deserialize)] diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index ad0dde1781..9acc840832 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -33,24 +33,24 @@ pub fn workspaced_service() -> Router { .route("/list", get(list_groups)) .route("/listnames", get(list_group_names)) .route("/create", post(create_group)) - .route("/get/:name", get(get_group)) - .route("/update/:name", post(update_group)) - .route("/delete/:name", delete(delete_group)) - .route("/adduser/:name", post(add_user)) - .route("/removeuser/:name", post(remove_user)) - .route("/is_owner/:name", get(is_owner)) + .route("/get/{name}", get(get_group)) + .route("/update/{name}", post(update_group)) + .route("/delete/{name}", delete(delete_group)) + .route("/adduser/{name}", post(add_user)) + .route("/removeuser/{name}", post(remove_user)) + .route("/is_owner/{name}", get(is_owner)) } pub fn global_service() -> Router { Router::new() .route("/list", get(list_igroups)) .route("/list_with_workspaces", get(list_igroups_with_workspaces)) - .route("/get/:name", get(get_igroup)) + .route("/get/{name}", get(get_igroup)) .route("/create", post(create_igroup)) - .route("/update/:name", post(update_igroup)) - .route("/delete/:name", delete(delete_igroup)) - .route("/adduser/:name", post(add_user_igroup)) - .route("/removeuser/:name", post(remove_user_igroup)) + .route("/update/{name}", post(update_igroup)) + .route("/delete/{name}", delete(delete_igroup)) + .route("/adduser/{name}", post(add_user_igroup)) + .route("/removeuser/{name}", post(remove_user_igroup)) .route("/export", get(export_igroups)) .route("/overwrite", post(overwrite_igroups)) } diff --git a/backend/windmill-api-inputs/src/lib.rs b/backend/windmill-api-inputs/src/lib.rs index e253d02ea1..9915b9459d 100644 --- a/backend/windmill-api-inputs/src/lib.rs +++ b/backend/windmill-api-inputs/src/lib.rs @@ -33,9 +33,9 @@ pub fn workspaced_service() -> Router { .route("/list", get(list_saved_inputs)) .route("/create", post(create_input)) .route("/update", post(update_input)) - .route("/delete/:id", post(delete_input)) + .route("/delete/{id}", post(delete_input)) .route( - "/:job_or_input_id/args", + "/{job_or_input_id}/args", get(get_args_from_history_or_saved_input), ) } diff --git a/backend/windmill-api-integration-tests/Cargo.toml b/backend/windmill-api-integration-tests/Cargo.toml index cb4827e857..eb432e463f 100644 --- a/backend/windmill-api-integration-tests/Cargo.toml +++ b/backend/windmill-api-integration-tests/Cargo.toml @@ -40,3 +40,4 @@ aws-config = { workspace = true, optional = true } aws-credential-types = { workspace = true, optional = true } aws-sdk-sqs = { workspace = true, optional = true } base64 = { workspace = true, optional = true } +axum.workspace = true diff --git a/backend/windmill-api-integration-tests/tests/ai_routes.rs b/backend/windmill-api-integration-tests/tests/ai_routes.rs new file mode 100644 index 0000000000..543c3ed2a8 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/ai_routes.rs @@ -0,0 +1,106 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +/// Start a mock AI API that echoes back a valid chat completion response. +async fn start_mock_ai_api() -> u16 { + use axum::{routing::post, Json, Router}; + + let app = Router::new().fallback(post(|| async { + Json(json!({ + "id": "chatcmpl-test", + "object": "chat.completion", + "choices": [{"message": {"role": "assistant", "content": "hello"}}] + })) + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + port +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_ai_proxy_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Start mock AI API + let mock_port = start_mock_ai_api().await; + let mock_url = format!("http://127.0.0.1:{mock_port}/v1"); + + // Create an openai resource pointing to the mock + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/resources/create" + )) + .json(&json!({ + "path": "f/ai/openai_config", + "resource_type": "openai", + "value": { + "api_key": "test-key", + "base_url": mock_url + } + })), + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "create openai resource", + ); + + // Set ai_config on workspace_settings directly via SQL + sqlx::query( + "UPDATE workspace_settings SET ai_config = $1::jsonb WHERE workspace_id = 'test-workspace'", + ) + .bind(json!({ + "providers": { + "openai": { + "resource_path": "f/ai/openai_config", + "models": ["gpt-4"] + } + } + })) + .execute(&db) + .await?; + + // POST /w/{ws}/ai/proxy/chat/completions + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /ai/proxy/chat/completions", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/audit.rs b/backend/windmill-api-integration-tests/tests/audit.rs new file mode 100644 index 0000000000..31994d8165 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/audit.rs @@ -0,0 +1,35 @@ +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_audit_endpoints(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/w/test-workspace/audit"); + + // GET /list returns 200 (empty array) + let resp = authed(client().get(format!("{base}/list"))).send().await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /audit/list", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/capture_unauthed.rs b/backend/windmill-api-integration-tests/tests/capture_unauthed.rs new file mode 100644 index 0000000000..f30a6e31b3 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/capture_unauthed.rs @@ -0,0 +1,83 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_capture_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // POST /capture/set_config → 200 (authed) + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/capture/set_config" + )) + .json(&json!({ + "trigger_kind": "webhook", + "path": "u/test-user/test_capture", + "is_flow": false + })), + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /capture/set_config"); + + // GET /capture/list/{...} → 200 (authed) + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/test-workspace/capture/list/script/u/test-user/test_capture" + ))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx( + status, + &body, + "GET /capture/list/script/u/test-user/test_capture", + ); + + // POST /capture/ping_config/{trigger_kind}/{runnable_kind}/{*path} → 200 + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/capture/ping_config/webhook/script/u/test-user/test_capture" + ))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /capture/ping_config", + ); + + // GET /capture/get_configs/{runnable_kind}/{*path} → 200 + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/test-workspace/capture/get_configs/script/u/test-user/test_capture" + ))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /capture/get_configs", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/concurrency_groups.rs b/backend/windmill-api-integration-tests/tests/concurrency_groups.rs new file mode 100644 index 0000000000..e4cf4ab8be --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/concurrency_groups.rs @@ -0,0 +1,48 @@ +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_concurrency_groups_2xx(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = authed(client().get(format!( + "http://localhost:{port}/api/concurrency_groups/list" + ))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /api/concurrency_groups/list", + ); + + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/test-workspace/concurrency_groups/list_jobs" + ))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /api/w/test-workspace/concurrency_groups/list_jobs", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/favorites.rs b/backend/windmill-api-integration-tests/tests/favorites.rs new file mode 100644 index 0000000000..888425eef7 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/favorites.rs @@ -0,0 +1,72 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_favorites_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + // Setup: create a script to favorite + let resp = authed(client().post(format!("{ws}/scripts/create"))) + .json(&json!({ + "path": "u/test-user/test_fav_script", + "summary": "test", + "description": "", + "content": "export function main() { return 1; }", + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + })) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /scripts/create (setup)"); + + let fav_body = json!({ + "favorite_kind": "script", + "path": "u/test-user/test_fav_script" + }); + + // POST /favorites/star → 200 + let resp = authed(client().post(format!("{ws}/favorites/star"))) + .json(&fav_body) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /favorites/star"); + + // POST /favorites/unstar → 200 + let resp = authed(client().post(format!("{ws}/favorites/unstar"))) + .json(&fav_body) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /favorites/unstar"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/folder_history.rs b/backend/windmill-api-integration-tests/tests/folder_history.rs new file mode 100644 index 0000000000..065977f386 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/folder_history.rs @@ -0,0 +1,51 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_folder_history_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Create a folder first + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/folders/create" + )) + .json(&json!({"name": "test_hist_folder", "owners": ["u/test-user"]})), + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /folders/create"); + + // GET /folders_history/get/{folder} → 200 (empty array) + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/test-workspace/folders_history/get/test_hist_folder" + ))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /folders_history/get/test_hist_folder"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/granular_acls.rs b/backend/windmill-api-integration-tests/tests/granular_acls.rs new file mode 100644 index 0000000000..c9cbbf0cf4 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/granular_acls.rs @@ -0,0 +1,54 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_granular_acls_endpoints(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/w/test-workspace/acls"); + + // GET /acls/get/group_/all → 200 + let resp = authed(client().get(format!("{base}/get/group_/all"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /acls/get/group_/all"); + + // POST /acls/add/group_/all → 200 + let resp = authed(client().post(format!("{base}/add/group_/all"))) + .json(&json!({"owner": "u/test-user-2", "write": true})) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /acls/add/group_/all"); + + // POST /acls/remove/group_/all → 200 + let resp = authed(client().post(format!("{base}/remove/group_/all"))) + .json(&json!({"owner": "u/test-user-2"})) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /acls/remove/group_/all"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/group_history.rs b/backend/windmill-api-integration-tests/tests/group_history.rs new file mode 100644 index 0000000000..5a18901454 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/group_history.rs @@ -0,0 +1,32 @@ +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_group_history_2xx(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/w/test-workspace/groups_history"); + + let resp = authed(client().get(format!("{base}/get/all"))) + .send() + .await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get/all"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/health.rs b/backend/windmill-api-integration-tests/tests/health.rs new file mode 100644 index 0000000000..c804373697 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/health.rs @@ -0,0 +1,42 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_health_endpoints(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/health"); + + // GET /health/status → 200 (no auth required) + let resp = client().get(format!("{base}/status")).send().await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /health/status"); + + // GET /health/detailed → 200 (authed) + let resp = authed(client().get(format!("{base}/detailed"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /health/detailed"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/inputs.rs b/backend/windmill-api-integration-tests/tests/inputs.rs new file mode 100644 index 0000000000..6f806efdb5 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/inputs.rs @@ -0,0 +1,76 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_inputs_endpoints(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/w/test-workspace/inputs"); + + // GET /history with fake runnable → 200 empty array + let resp = authed(client().get(format!( + "{base}/history?runnable_id=u/test-user/test&runnable_type=ScriptPath" + ))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /inputs/history"); + + // GET /list with fake runnable → 200 empty array + let resp = authed(client().get(format!( + "{base}/list?runnable_id=u/test-user/test&runnable_type=ScriptPath" + ))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /inputs/list"); + + // POST /create → 200, returns UUID + let resp = authed(client().post(format!( + "{base}/create?runnable_id=u/test-user/test&runnable_type=ScriptPath" + ))) + .json(&json!({"name": "test_input", "args": {}})) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /inputs/create"); + let input_id: String = serde_json::from_str(&body)?; + + // GET /{id}/args → 200 + let resp = authed(client().get(format!("{base}/{input_id}/args"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /inputs/{id}/args"); + + // POST /delete/{id} → 200 + let resp = authed(client().post(format!("{base}/delete/{input_id}"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /inputs/delete/{id}"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/job_metrics.rs b/backend/windmill-api-integration-tests/tests/job_metrics.rs new file mode 100644 index 0000000000..7408e4d3e3 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/job_metrics.rs @@ -0,0 +1,59 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +const FAKE_UUID: &str = "00000000-0000-0000-0000-000000000000"; + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_job_metrics_2xx(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/w/test-workspace/job_metrics"); + + let resp = authed(client().post(format!("{base}/get/{FAKE_UUID}"))) + .json(&json!({})) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /get/{id}", + ); + + let resp = authed(client().post(format!("{base}/set_progress/{FAKE_UUID}"))) + .json(&json!({"percent": 50})) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /set_progress/{id}", + ); + + let resp = authed(client().get(format!("{base}/get_progress/{FAKE_UUID}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_progress/{id}", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs new file mode 100644 index 0000000000..4e82a4aba1 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -0,0 +1,308 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +fn assert_route_reachable(status: u16, body: &str, endpoint: &str) { + assert!( + status != 404 || !body.is_empty(), + "Router-level 404 for {endpoint}", + ); +} + +async fn insert_completed_job(db: &Pool) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status) + VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + id +} + +async fn create_script(port: u16) -> String { + let base = format!("http://localhost:{port}/api/w/test-workspace/scripts"); + let resp = authed(client().post(format!("{base}/create"))) + .json(&json!({ + "path": "u/test-user/test_job_script", + "summary": "test", + "description": "", + "content": "export function main() { return 42; }", + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + })) + .send() + .await + .unwrap(); + assert!( + resp.status().is_success(), + "create script: {}", + resp.status() + ); + "u/test-user/test_job_script".to_string() +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_authed_list_and_count(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/w/test-workspace/jobs"); + + // --- List/count endpoints (2xx with empty results) --- + + let resp = authed(client().get(format!("{base}/list"))).send().await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/list", + ); + + let resp = authed(client().get(format!("{base}/queue/list"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/queue/list", + ); + + let resp = authed(client().get(format!("{base}/queue/count"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/queue/count", + ); + + let resp = authed(client().get(format!("{base}/completed/list"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/list", + ); + + let resp = authed(client().get(format!("{base}/completed/count"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/count", + ); + + // --- Global endpoints --- + + let resp = client() + .get(format!("http://localhost:{port}/api/jobs/db_clock")) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/db_clock", + ); + + let resp = authed(client().get(format!( + "http://localhost:{port}/api/jobs/completed/count_by_tag" + ))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/count_by_tag", + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_authed_completed_endpoints(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/w/test-workspace/jobs"); + + let job_id = insert_completed_job(&db).await; + + let resp = authed(client().get(format!("{base}/completed/get/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/get", + ); + + let resp = authed(client().get(format!("{base}/completed/get_result/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/get_result", + ); + + let resp = authed(client().get(format!("{base}/completed/get_result_maybe/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/get_result_maybe", + ); + + let resp = authed(client().get(format!("{base}/completed/get_timing/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/get_timing", + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_authed_run_endpoints(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/w/test-workspace/jobs"); + + // Run preview — no pre-existing script needed + let resp = authed(client().post(format!("{base}/run/preview"))) + .json(&json!({ + "content": "export function main() { return 1; }", + "language": "deno", + "args": {} + })) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /jobs/run/preview", + ); + + // Run preview flow + let resp = authed(client().post(format!("{base}/run/preview_flow"))) + .json(&json!({ + "value": {"modules": []}, + "args": {} + })) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /jobs/run/preview_flow", + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_authed_reachability(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/w/test-workspace/jobs"); + let fake = Uuid::nil(); + + // These need complex runtime but should hit the handler (not 404) + + let resp = authed(client().post(format!("{base}/flow/resume/{fake}"))) + .json(&json!({})) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /jobs/flow/resume", + ); + + let resp = authed(client().get(format!("{base}/job_signature/{fake}/1"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/job_signature", + ); + + let resp = authed(client().get(format!("{base}/resume_urls/{fake}/1"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/resume_urls", + ); + + let resp = authed(client().get(format!("{base}/result_by_id/{fake}/step1"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/result_by_id", + ); + + let resp = authed(client().post(format!("{base}/restart/f/{fake}"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /jobs/restart/f", + ); + + let resp = authed(client().post(format!("{base}/run/workflow_as_code/{fake}/main"))) + .json(&json!({})) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /jobs/run/workflow_as_code", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/jobs_unauthed.rs b/backend/windmill-api-integration-tests/tests/jobs_unauthed.rs new file mode 100644 index 0000000000..fa8b6cb66f --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/jobs_unauthed.rs @@ -0,0 +1,250 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +/// Insert a minimal completed job directly into the database for testing. +async fn insert_completed_job(db: &Pool) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status) + VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + + id +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_unauthed_endpoints(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/w/test-workspace/jobs_u"); + + let job_id = insert_completed_job(&db).await; + + // --- No-data endpoints --- + + let resp = authed(client().post(format!("{base}/queue/get_started_at_by_ids"))) + .json(&json!([])) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /queue/get_started_at_by_ids", + ); + + // --- Completed job endpoints (unauthed service, with auth header) --- + + let resp = authed(client().get(format!("{base}/get/{job_id}"))) + .send() + .await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get"); + + let resp = authed(client().get(format!("{base}/get_logs/{job_id}"))) + .send() + .await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get_logs"); + + let resp = authed(client().get(format!("{base}/get_completed_logs_tail/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_completed_logs_tail", + ); + + let resp = authed(client().get(format!("{base}/get_args/{job_id}"))) + .send() + .await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get_args"); + + let resp = authed(client().get(format!("{base}/completed/get/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /completed/get", + ); + + let resp = authed(client().get(format!("{base}/completed/get_result/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /completed/get_result", + ); + + let resp = authed(client().get(format!("{base}/completed/get_result_maybe/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /completed/get_result_maybe", + ); + + let resp = authed(client().get(format!("{base}/completed/get_timing/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /completed/get_timing", + ); + + let resp = authed(client().get(format!("{base}/getupdate/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /getupdate", + ); + + Ok(()) +} + +const FAKE_UUID: &str = "00000000-0000-0000-0000-000000000000"; +const FAKE_SECRET: &str = "aabb"; + +/// Reachability tests for endpoints that need complex runtime. +/// These just verify the route matches (handler runs), not 2xx. +fn assert_route_reachable(status: u16, body: &str, endpoint: &str) { + assert!( + status != 404 || !body.is_empty(), + "Router-level 404 for {endpoint}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_unauthed_complex_reachability(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/w/test-workspace/jobs_u"); + + let resp = authed(client().get(format!("{base}/resume/{FAKE_UUID}/1/{FAKE_SECRET}"))) + .send() + .await?; + assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "GET /resume"); + + let resp = authed(client().post(format!("{base}/cancel/{FAKE_UUID}/1/{FAKE_SECRET}"))) + .send() + .await?; + assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "POST /cancel"); + + let resp = authed(client().get(format!("{base}/get_flow/{FAKE_UUID}/1/{FAKE_SECRET}"))) + .send() + .await?; + assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "GET /get_flow"); + + let resp = authed(client().post(format!("{base}/queue/cancel/{FAKE_UUID}"))) + .json(&serde_json::json!({"reason": "test"})) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /queue/cancel", + ); + + let resp = authed(client().post(format!("{base}/queue/force_cancel/{FAKE_UUID}"))) + .json(&serde_json::json!({"reason": "test"})) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /queue/force_cancel", + ); + + let resp = authed(client().post(format!("{base}/flow/resume_suspended/{FAKE_UUID}"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /flow/resume_suspended", + ); + + let resp = authed(client().get(format!("{base}/flow/approval_info/{FAKE_UUID}"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /flow/approval_info", + ); + + let resp = authed(client().get(format!("{base}/get_root_job_id/{FAKE_UUID}"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_root_job_id", + ); + + let resp = authed(client().get(format!("{base}/get_flow_debug_info/{FAKE_UUID}"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_flow_debug_info", + ); + + let resp = authed(client().get(format!("{base}/get_log_file/{FAKE_UUID}/test.txt"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_log_file", + ); + + let resp = authed(client().post(format!("{base}/queue/cancel_persistent/u/test-user/fake"))) + .json(&serde_json::json!({"reason": "test"})) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /queue/cancel_persistent", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/npm_proxy.rs b/backend/windmill-api-integration-tests/tests/npm_proxy.rs new file mode 100644 index 0000000000..9f3e813807 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/npm_proxy.rs @@ -0,0 +1,85 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +/// Start a mock npm registry that returns valid JSON for any GET request. +async fn start_mock_registry() -> u16 { + use axum::{routing::get, Json, Router}; + + let app = Router::new().fallback(get(|| async { + Json(json!({ + "name": "test-package", + "versions": {"1.0.0": {"name": "test-package", "version": "1.0.0"}}, + "dist-tags": {"latest": "1.0.0"} + })) + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + port +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_npm_proxy_endpoints(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/w/test-workspace/npm_proxy"); + + // Start mock npm registry + let mock_port = start_mock_registry().await; + let mock_url = format!("http://127.0.0.1:{mock_port}"); + + // Configure the npm registry to point to our mock + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/settings/global/npm_config_registry" + )) + .json(&json!({"value": mock_url})), + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /settings/global/npm_config_registry", + ); + + // GET /metadata/{package} + let resp = authed(client().get(format!("{base}/metadata/lodash"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /npm_proxy/metadata/lodash", + ); + + // GET /resolve/{package} + let resp = authed(client().get(format!("{base}/resolve/lodash"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /npm_proxy/resolve/lodash", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/raw_apps.rs b/backend/windmill-api-integration-tests/tests/raw_apps.rs new file mode 100644 index 0000000000..97f90709b1 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/raw_apps.rs @@ -0,0 +1,33 @@ +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_raw_apps_endpoints(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/w/test-workspace/raw_apps"); + + // GET /raw_apps/list → 200 (empty array) + let resp = authed(client().get(format!("{base}/list"))).send().await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /raw_apps/list"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/service_logs.rs b/backend/windmill-api-integration-tests/tests/service_logs.rs new file mode 100644 index 0000000000..0c66916053 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/service_logs.rs @@ -0,0 +1,36 @@ +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_service_logs_2xx(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/service_logs"); + + let resp = authed(client().get(format!("{base}/list_files"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /list_files", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/settings.rs b/backend/windmill-api-integration-tests/tests/settings.rs new file mode 100644 index 0000000000..8f21ffb483 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/settings.rs @@ -0,0 +1,116 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_settings_2xx(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/settings"); + + let resp = authed(client().get(format!("{base}/envs"))).send().await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /envs"); + + let resp = authed(client().get(format!("{base}/global/hub_base_url"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /global/hub_base_url", + ); + + let resp = authed(client().post(format!("{base}/global/test_key"))) + .json(&json!({"value": "test"})) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /global/test_key", + ); + + let resp = authed(client().get(format!("{base}/instance_config"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /instance_config", + ); + + let resp = authed(client().get(format!("{base}/instance_config/yaml"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /instance_config/yaml", + ); + + let resp = authed(client().get(format!("{base}/latest_key_renewal_attempt"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /latest_key_renewal_attempt", + ); + + let resp = authed(client().post(format!("{base}/sync_cached_resource_types"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /sync_cached_resource_types", + ); + + // --- Reachability only (need external services) --- + + let resp = authed( + client() + .post(format!("{base}/test_smtp")) + .json(&json!({"to": "test@test.com", "subject": "test", "content": "test"})), + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert!( + status != 404 || !body.is_empty(), + "Router-level 404 for POST /test_smtp" + ); + + let resp = authed( + client() + .post(format!("{base}/test_license_key")) + .json(&json!({"license_key": "fake"})), + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert!( + status != 404 || !body.is_empty(), + "Router-level 404 for POST /test_license_key" + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/trash.rs b/backend/windmill-api-integration-tests/tests/trash.rs new file mode 100644 index 0000000000..df32a5b3fc --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/trash.rs @@ -0,0 +1,41 @@ +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_trash_endpoints(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/w/test-workspace/trash"); + + // GET /trash/list → 200 (admin, empty array) + let resp = authed(client().get(format!("{base}/list"))).send().await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /trash/list"); + + // POST /trash/empty → 200 (admin) + let resp = authed(client().post(format!("{base}/empty"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /trash/empty"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/workspace_deps.rs b/backend/windmill-api-integration-tests/tests/workspace_deps.rs new file mode 100644 index 0000000000..dcfe8877dd --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/workspace_deps.rs @@ -0,0 +1,39 @@ +use sqlx::{Pool, Postgres}; +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 assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_workspace_deps_2xx(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/w/test-workspace/workspace_dependencies"); + + let resp = authed(client().get(format!("{base}/list"))).send().await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /list"); + + let resp = authed(client().get(format!("{base}/get_latest/python3"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_latest/python3", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index 55e00eb05d..131cfbbae5 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -599,59 +599,60 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .unwrap(); assert_eq!(resp.status(), 200, "tarball: {}", resp.status()); - // ===== Fork operations (on the newly created workspace) ===== + // ===== Fork operations (EE-only: CE limits workspace count to 2) ===== + #[cfg(feature = "enterprise")] + { + let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces"); + let resp = authed(client().post(format!("{new_ws_base}/create_fork"))) + .json(&json!({ + "id": "wm-fork-test-ws", + "name": "Forked Test Workspace" + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?); - // --- create_fork (workspace-scoped, from new-test-ws) --- - let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces"); - let resp = authed(client().post(format!("{new_ws_base}/create_fork"))) - .json(&json!({ - "id": "wm-fork-test-ws", - "name": "Forked Test Workspace" - })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?); + // verify fork exists + let resp = authed(client().post(format!("{global_base}/exists"))) + .json(&json!({"id": "wm-fork-test-ws"})) + .send() + .await + .unwrap(); + assert_eq!(resp.json::().await?, true); - // verify fork exists - let resp = authed(client().post(format!("{global_base}/exists"))) - .json(&json!({"id": "wm-fork-test-ws"})) - .send() - .await - .unwrap(); - assert_eq!(resp.json::().await?, true); + // --- change_workspace_id --- + let fork_ws_base = format!("http://localhost:{port}/api/w/wm-fork-test-ws/workspaces"); + let resp = authed(client().post(format!("{fork_ws_base}/change_workspace_id"))) + .json(&json!({ + "new_id": "wm-fork-renamed", + "new_name": "Renamed Fork" + })) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 200, + "change_workspace_id: {}", + resp.text().await? + ); - // --- change_workspace_id --- - let fork_ws_base = format!("http://localhost:{port}/api/w/wm-fork-test-ws/workspaces"); - let resp = authed(client().post(format!("{fork_ws_base}/change_workspace_id"))) - .json(&json!({ - "new_id": "wm-fork-renamed", - "new_name": "Renamed Fork" - })) - .send() - .await - .unwrap(); - assert_eq!( - resp.status(), - 200, - "change_workspace_id: {}", - resp.text().await? - ); + // verify renamed workspace exists + let resp = authed(client().post(format!("{global_base}/exists"))) + .json(&json!({"id": "wm-fork-renamed"})) + .send() + .await + .unwrap(); + assert_eq!(resp.json::().await?, true); - // verify renamed workspace exists - let resp = authed(client().post(format!("{global_base}/exists"))) - .json(&json!({"id": "wm-fork-renamed"})) - .send() - .await - .unwrap(); - assert_eq!(resp.json::().await?, true); - - // clean up renamed fork - let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed"))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); + // clean up renamed fork + let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + } // --- archive workspace (on the newly created one, not our main test workspace) --- let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces"); @@ -803,3 +804,21 @@ async fn test_get_copilot_info_ignores_empty_instance_ai_row( Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_get_imports(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/w/test-workspace/workspaces"); + + let resp = authed(client().get(format!("{base}/get_imports/u/test-user/nonexistent_script"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let imports = resp.json::>().await?; + assert!(imports.is_empty()); + + Ok(()) +} diff --git a/backend/windmill-api-jobs/src/concurrency_groups.rs b/backend/windmill-api-jobs/src/concurrency_groups.rs index 33f6045e49..f3ef3b14a8 100644 --- a/backend/windmill-api-jobs/src/concurrency_groups.rs +++ b/backend/windmill-api-jobs/src/concurrency_groups.rs @@ -25,8 +25,8 @@ use uuid::Uuid; pub fn global_service() -> Router { Router::new() .route("/list", get(list_concurrency_groups)) - .route("/prune/*concurrency_key", delete(prune_concurrency_group)) - .route("/:job_id/key", get(get_concurrency_key)) + .route("/prune/{*concurrency_key}", delete(prune_concurrency_group)) + .route("/{job_id}/key", get(get_concurrency_key)) } pub fn workspaced_service() -> Router { diff --git a/backend/windmill-api-jobs/src/job_metrics.rs b/backend/windmill-api-jobs/src/job_metrics.rs index ad316150c0..59cb23deff 100644 --- a/backend/windmill-api-jobs/src/job_metrics.rs +++ b/backend/windmill-api-jobs/src/job_metrics.rs @@ -22,13 +22,13 @@ pub fn workspaced_service() -> Router { .allow_origin(Any); Router::new() - .route("/get/:id", post(get_job_metrics).layer(cors.clone())) + .route("/get/{id}", post(get_job_metrics).layer(cors.clone())) .route( - "/set_progress/:id", + "/set_progress/{id}", post(set_job_progress).layer(cors.clone()), ) .route( - "/get_progress/:id", + "/get_progress/{id}", get(get_job_progress).layer(cors.clone()), ) } diff --git a/backend/windmill-api-jobs/src/types.rs b/backend/windmill-api-jobs/src/types.rs index c59a54bd1c..f040e6eeb7 100644 --- a/backend/windmill-api-jobs/src/types.rs +++ b/backend/windmill-api-jobs/src/types.rs @@ -511,18 +511,14 @@ pub struct ResumeUrls { pub struct QueryOrBody(pub Option); -#[axum::async_trait] -impl FromRequest for QueryOrBody +impl FromRequest for QueryOrBody where D: DeserializeOwned, S: Send + Sync, { type Rejection = Response; - async fn from_request( - req: Request, - state: &S, - ) -> std::result::Result { + async fn from_request(req: Request, state: &S) -> std::result::Result { return if req.method() == axum::http::Method::GET { let Query(InPayload { payload }) = Query::from_request(req, state) .await diff --git a/backend/windmill-api-npm-proxy/src/lib.rs b/backend/windmill-api-npm-proxy/src/lib.rs index 903c2fb33c..25a3dd22d8 100644 --- a/backend/windmill-api-npm-proxy/src/lib.rs +++ b/backend/windmill-api-npm-proxy/src/lib.rs @@ -119,10 +119,10 @@ struct FileEntry { pub fn workspaced_service() -> Router { Router::new() // Use wildcards for package names to support scoped packages like @scope/package - .route("/metadata/*package", get(get_package_metadata)) - .route("/resolve/*package", get(resolve_package_version)) - .route("/filetree/*package_version", get(get_package_filetree)) - .route("/file/*package_version_filepath", get(get_package_file)) + .route("/metadata/{*package}", get(get_package_metadata)) + .route("/resolve/{*package}", get(resolve_package_version)) + .route("/filetree/{*package_version}", get(get_package_filetree)) + .route("/file/{*package_version_filepath}", get(get_package_file)) .layer( CorsLayer::new() .allow_origin(Any) diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index fc12ccc043..c29d0c0ac2 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -56,12 +56,12 @@ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_schedule)) .route("/list_with_jobs", get(list_schedule_with_jobs)) - .route("/get/*path", get(get_schedule)) - .route("/exists/*path", get(exists_schedule)) + .route("/get/{*path}", get(get_schedule)) + .route("/exists/{*path}", get(exists_schedule)) .route("/create", post(create_schedule)) - .route("/update/*path", post(edit_schedule)) - .route("/delete/*path", delete(delete_schedule)) - .route("/setenabled/*path", post(set_enabled)) + .route("/update/{*path}", post(edit_schedule)) + .route("/delete/{*path}", delete(delete_schedule)) + .route("/setenabled/{*path}", post(set_enabled)) .route("/setdefaulthandler", post(set_default_error_handler)) // .route("/catchup/*path", post(do_catchup).get(list_catchup)) } diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index e5b66f5c28..5e3c3eb6df 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -190,18 +190,18 @@ impl ScriptWDraft { pub fn global_service() -> Router { Router::new() .route("/hub/top", get(get_top_hub_scripts)) - .route("/hub/get/*path", get(get_hub_script_by_path)) - .route("/hub/get_full/*path", get(get_full_hub_script_by_path)) - .route("/hub/pick/*path", get(pick_hub_script_by_path)) + .route("/hub/get/{*path}", get(get_hub_script_by_path)) + .route("/hub/get_full/{*path}", get(get_full_hub_script_by_path)) + .route("/hub/pick/{*path}", get(pick_hub_script_by_path)) } pub fn global_unauthed_service() -> Router { Router::new() .route( - "/tokened_raw/:workspace/:token/*path", + "/tokened_raw/{workspace}/{token}/{*path}", get(get_tokened_raw_script_by_path), ) - .route("/empty_ts/*path", get(get_empty_ts_script_by_path)) + .route("/empty_ts/{*path}", get(get_empty_ts_script_by_path)) } pub fn workspaced_service() -> Router { @@ -210,33 +210,33 @@ pub fn workspaced_service() -> Router { .route("/list_search", get(list_search_scripts)) .route("/create", post(create_script)) .route("/create_snapshot", post(create_snapshot_script)) - .route("/archive/p/*path", post(archive_script_by_path)) - .route("/get/draft/*path", get(get_script_by_path_w_draft)) - .route("/get/p/*path", get(get_script_by_path)) - .route("/list_tokens/*path", get(list_tokens)) - .route("/raw/p/*path", get(raw_script_by_path)) - .route("/raw_unpinned/p/*path", get(raw_script_by_path_unpinned)) - .route("/exists/p/*path", get(exists_script_by_path)) - .route("/archive/h/:hash", post(archive_script_by_hash)) - .route("/delete/h/:hash", post(delete_script_by_hash)) - .route("/delete/p/*path", post(delete_script_by_path)) + .route("/archive/p/{*path}", post(archive_script_by_path)) + .route("/get/draft/{*path}", get(get_script_by_path_w_draft)) + .route("/get/p/{*path}", get(get_script_by_path)) + .route("/list_tokens/{*path}", get(list_tokens)) + .route("/raw/p/{*path}", get(raw_script_by_path)) + .route("/raw_unpinned/p/{*path}", get(raw_script_by_path_unpinned)) + .route("/exists/p/{*path}", get(exists_script_by_path)) + .route("/archive/h/{hash}", post(archive_script_by_hash)) + .route("/delete/h/{hash}", post(delete_script_by_hash)) + .route("/delete/p/{*path}", post(delete_script_by_path)) .route("/delete_bulk", delete(delete_scripts_bulk)) - .route("/get/h/:hash", get(get_script_by_hash)) - .route("/raw/h/:hash", get(raw_script_by_hash)) - .route("/deployment_status/h/:hash", get(get_deployment_status)) + .route("/get/h/{hash}", get(get_script_by_hash)) + .route("/raw/h/{hash}", get(raw_script_by_hash)) + .route("/deployment_status/h/{hash}", get(get_deployment_status)) .route("/list_paths", get(list_paths)) .route( - "/toggle_workspace_error_handler/p/*path", + "/toggle_workspace_error_handler/p/{*path}", post(toggle_workspace_error_handler), ) - .route("/history/p/*path", get(get_script_history)) - .route("/get_latest_version/*path", get(get_latest_version)) + .route("/history/p/{*path}", get(get_script_history)) + .route("/get_latest_version/{*path}", get(get_latest_version)) .route( - "/list_paths_from_workspace_runnable/*path", + "/list_paths_from_workspace_runnable/{*path}", get(list_paths_from_workspace_runnable), ) .route( - "/history_update/h/:hash/p/*path", + "/history_update/h/{hash}/p/{*path}", post(update_script_history), ) .route("/list_dedicated_with_deps", get(list_dedicated_with_deps)) diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index a1ea80f173..17954b1f11 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -58,7 +58,7 @@ pub fn global_service() -> Router { let r = Router::new() .route("/envs", get(get_local_settings)) .route( - "/global/:key", + "/global/{key}", post(set_global_setting).get(get_global_setting), ) .route("/list_global", get(list_global_settings)) @@ -80,7 +80,7 @@ pub fn global_service() -> Router { .route("/test_critical_channels", post(test_critical_channels)) .route("/critical_alerts", get(get_critical_alerts)) .route( - "/critical_alerts/:id/acknowledge", + "/critical_alerts/{id}/acknowledge", post(acknowledge_critical_alert), ) .route( @@ -92,7 +92,7 @@ pub fn global_service() -> Router { post(refresh_custom_instance_user_pwd), ) .route( - "/setup_custom_instance_pg_database/:name", + "/setup_custom_instance_pg_database/{name}", post(setup_custom_instance_pg_database), ) .route( diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 8790a9c541..b3b32859c7 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -66,32 +66,32 @@ pub fn workspaced_service() -> Router { .route("/list_usage", get(list_user_usage)) .route("/list_usernames", get(list_usernames)) .route("/exists", post(exists_username)) - .route("/get/:user", get(get_workspace_user)) - .route("/update/:user", post(update_workspace_user)) - .route("/delete/:user", delete(delete_workspace_user)) - .route("/convert_to_group/:user", post(convert_user_to_group)) - .route("/is_owner/*path", get(is_owner_of_path)) - .route("/whois/:username", get(whois)) + .route("/get/{user}", get(get_workspace_user)) + .route("/update/{user}", post(update_workspace_user)) + .route("/delete/{user}", delete(delete_workspace_user)) + .route("/convert_to_group/{user}", post(convert_user_to_group)) + .route("/is_owner/{*path}", get(is_owner_of_path)) + .route("/whois/{username}", get(whois)) .route("/whoami", get(whoami)) .route("/leave", post(leave_workspace)) - .route("/username_to_email/:username", get(username_to_email)) + .route("/username_to_email/{username}", get(username_to_email)) } pub fn global_service() -> Router { Router::new() - .route("/exists/:email", get(exists_email)) + .route("/exists/{email}", get(exists_email)) .route("/email", get(get_email)) .route("/whoami", get(global_whoami)) .route("/list_invites", get(list_invites)) .route("/decline_invite", post(decline_invite)) .route("/accept_invite", post(accept_invite)) .route("/list_as_super_admin", get(list_users_as_super_admin)) - .route("/set_login_type/:user", post(set_login_type)) - .route("/update/:user", post(update_user)) - .route("/delete/:user", delete(delete_user)) - .route("/username_info/:user", get(get_instance_username_info)) + .route("/set_login_type/{user}", post(set_login_type)) + .route("/update/{user}", post(update_user)) + .route("/delete/{user}", delete(delete_user)) + .route("/username_info/{user}", get(get_instance_username_info)) .route("/tokens/create", post(create_token)) - .route("/tokens/delete/:token_prefix", delete(delete_token)) + .route("/tokens/delete/{token_prefix}", delete(delete_token)) .route("/tokens/list", get(list_tokens)) .route("/tokens/impersonate", post(impersonate)) .route("/usage", get(get_usage)) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 646a2369b9..fe7991d3a8 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -78,8 +78,8 @@ pub fn workspaced_service() -> Router { .route("/delete_invite", post(delete_invite)) .route("/rebuild_dependency_map", post(rebuild_dependency_map)) .route("/get_dependency_map", get(get_dependency_map)) - .route("/get_dependents/*imported_path", get(get_dependents)) - .route("/get_imports/*importer_path", get(get_imports)) + .route("/get_dependents/{*imported_path}", get(get_dependents)) + .route("/get_imports/{*importer_path}", get(get_imports)) .route("/get_dependents_amounts", post(get_dependents_amounts)) .route("/get_settings", get(get_settings)) .route( @@ -152,14 +152,14 @@ pub fn workspaced_service() -> Router { post(create_workspace_fork_branch), ) .route( - "/reset_diff_tally/:fork_workspace_id", + "/reset_diff_tally/{fork_workspace_id}", post(reset_workspace_diffs), ) - .route("/compare/:target_workspace_id", get(compare_workspaces)) + .route("/compare/{target_workspace_id}", get(compare_workspaces)) .route("/protection_rules", get(list_protection_rules)) .route("/protection_rules", post(create_protection_rule)) .route( - "/protection_rules/:rule_name", + "/protection_rules/{rule_name}", post(update_protection_rule).delete(delete_protection_rule), ) .route("/log_chat", post(log_ai_chat)) @@ -176,9 +176,9 @@ pub fn global_service() -> Router { .route("/exists", post(exists_workspace)) .route("/exists_username", post(exists_username)) .route("/allowed_domain_auto_invite", get(is_allowed_auto_domain)) - .route("/unarchive/:workspace", post(unarchive_workspace)) + .route("/unarchive/{workspace}", post(unarchive_workspace)) .route( - "/delete/:workspace", + "/delete/{workspace}", delete(crate::workspaces_extra::delete_workspace), ) .route( diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index d77a0fa8cc..16cfb0166c 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -607,11 +607,11 @@ fn transform_fim_to_chat_completions(body: &Bytes) -> Result<(Bytes, String)> { } pub fn global_service() -> Router { - Router::new().route("/proxy/*ai", post(global_proxy).get(global_proxy)) + Router::new().route("/proxy/{*ai}", post(global_proxy).get(global_proxy)) } pub fn workspaced_service() -> Router { - let router = Router::new().route("/proxy/*ai", post(proxy).get(proxy)); + let router = Router::new().route("/proxy/{*ai}", post(proxy).get(proxy)); #[cfg(feature = "bedrock")] let router = router.route("/check_bedrock_credentials", get(check_bedrock_credentials)); diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 9ff74387d1..33a68409a0 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -83,48 +83,54 @@ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_apps)) .route("/list_search", get(list_search_apps)) - .route("/get/p/*path", get(get_app)) - .route("/get/lite/*path", get(get_app_lite)) - .route("/get/draft/*path", get(get_app_w_draft)) - .route("/secret_of/*path", get(get_secret_id)) + .route("/get/p/{*path}", get(get_app)) + .route("/get/lite/{*path}", get(get_app_lite)) + .route("/get/draft/{*path}", get(get_app_w_draft)) + .route("/secret_of/{*path}", get(get_secret_id)) .route( - "/secret_of_latest_version/*path", + "/secret_of_latest_version/{*path}", get(get_latest_version_secret_id), ) - .route("/get/v/*id", get(get_app_by_id)) - .route("/get_data/v/*id", get(get_raw_app_data)) - .route("/exists/*path", get(exists_app)) - .route("/update/*path", post(update_app)) - .route("/update_raw/*path", post(update_app_raw)) - .route("/delete/*path", delete(delete_app)) + .route("/get/v/{*id}", get(get_app_by_id)) + .route("/get_data/v/{*id}", get(get_raw_app_data)) + .route("/exists/{*path}", get(exists_app)) + .route("/update/{*path}", post(update_app)) + .route("/update_raw/{*path}", post(update_app_raw)) + .route("/delete/{*path}", delete(delete_app)) .route("/create", post(create_app)) .route("/create_raw", post(create_app_raw)) - .route("/history/p/*path", get(get_app_history)) - .route("/get_latest_version/*path", get(get_latest_version)) - .route("/history_update/a/:id/v/:version", post(update_app_history)) + .route("/history/p/{*path}", get(get_app_history)) + .route("/get_latest_version/{*path}", get(get_latest_version)) .route( - "/list_paths_from_workspace_runnable/:runnable_kind/*path", + "/history_update/a/{id}/v/{version}", + post(update_app_history), + ) + .route( + "/list_paths_from_workspace_runnable/{runnable_kind}/{*path}", get(list_paths_from_workspace_runnable), ) - .route("/custom_path_exists/*custom_path", get(custom_path_exists)) + .route( + "/custom_path_exists/{*custom_path}", + get(custom_path_exists), + ) .route("/sign_s3_objects", post(sign_s3_objects)) } pub fn unauthed_service() -> Router { Router::new() - .route("/execute_component/*path", post(execute_component)) - .route("/upload_s3_file/*path", post(upload_s3_file_from_app)) + .route("/execute_component/{*path}", post(execute_component)) + .route("/upload_s3_file/{*path}", post(upload_s3_file_from_app)) .route("/delete_s3_file", delete(delete_s3_file_from_app)) - .route("/download_s3_file/*path", get(download_s3_file_from_app)) - .route("/public_app/:secret", get(get_public_app_by_secret)) - .route("/public_resource/*path", get(get_public_resource)) - .route("/get_data/v/*id", get(get_raw_app_data)) + .route("/download_s3_file/{*path}", get(download_s3_file_from_app)) + .route("/public_app/{secret}", get(get_public_app_by_secret)) + .route("/public_resource/{*path}", get(get_public_resource)) + .route("/get_data/v/{*id}", get(get_raw_app_data)) } pub fn global_service() -> Router { Router::new() .route("/hub/list", get(list_hub_apps)) - .route("/hub/get/:id", get(get_hub_app_by_id)) - .route("/hub/get_raw/:id", get(get_hub_raw_app_by_id)) + .route("/hub/get/{id}", get(get_hub_app_by_id)) + .route("/hub/get_raw/{id}", get(get_hub_raw_app_by_id)) } #[derive(FromRow, Deserialize, Serialize)] diff --git a/backend/windmill-api/src/args.rs b/backend/windmill-api/src/args.rs index 2734be6740..a881fb989d 100644 --- a/backend/windmill-api/src/args.rs +++ b/backend/windmill-api/src/args.rs @@ -451,8 +451,7 @@ where } } -#[axum::async_trait] -impl FromRequest for RawWebhookArgs +impl FromRequest for RawWebhookArgs where S: Send + Sync, { diff --git a/backend/windmill-api/src/audit.rs b/backend/windmill-api/src/audit.rs index 336fd32881..7f81df849c 100644 --- a/backend/windmill-api/src/audit.rs +++ b/backend/windmill-api/src/audit.rs @@ -19,7 +19,7 @@ use crate::db::ApiAuthed; pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_audit)) - .route("/get/:id", get(get_audit)) + .route("/get/{id}", get(get_audit)) } async fn get_audit( diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index 277504b481..c8ddea37d2 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -93,22 +93,22 @@ pub fn workspaced_service() -> Router { Router::new() .route("/set_config", post(set_config)) .route( - "/ping_config/:trigger_kind/:runnable_kind/*path", + "/ping_config/{trigger_kind}/{runnable_kind}/{*path}", post(ping_config), ) - .route("/get_configs/:runnable_kind/*path", get(get_configs)) - .route("/list/:runnable_kind/*path", get(list_captures)) + .route("/get_configs/{runnable_kind}/{*path}", get(get_configs)) + .route("/list/{runnable_kind}/{*path}", get(list_captures)) .route( - "/move/:runnable_kind/*path", + "/move/{runnable_kind}/{*path}", post(move_captures_and_configs), ) - .route("/:id", delete(delete_capture)) - .route("/:id", get(get_capture)) + .route("/{id}", delete(delete_capture)) + .route("/{id}", get(get_capture)) } pub fn workspaced_unauthed_service() -> Router { let router = Router::new().route( - "/webhook/:runnable_kind/*path", + "/webhook/{runnable_kind}/{*path}", head(|| async {}).post(webhook_payload), ); @@ -118,12 +118,12 @@ pub fn workspaced_unauthed_service() -> Router { ))] { #[cfg(feature = "http_trigger")] - let router = router.route("/http/:runnable_kind/:path/*route_path", { + let router = router.route("/http/{runnable_kind}/{path}/{*route_path}", { head(|| async {}).fallback(http_payload) }); #[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))] - let router = router.route("/gcp/:runnable_kind/*path", post(gcp_payload)); + let router = router.route("/gcp/{runnable_kind}/{*path}", post(gcp_payload)); router } diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 41e8d4709d..39a68d8f9a 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -23,7 +23,7 @@ use windmill_common::{db::UserDB, error::Result, utils::StripPath}; pub fn workspaced_service() -> Router { Router::new() .route("/create", post(create_draft)) - .route("/delete/:kind/*path", delete(delete_draft)) + .route("/delete/{kind}/{*path}", delete(delete_draft)) } #[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)] diff --git a/backend/windmill-api/src/flows.rs b/backend/windmill-api/src/flows.rs index c6a09a9e6a..15c9262967 100644 --- a/backend/windmill-api/src/flows.rs +++ b/backend/windmill-api/src/flows.rs @@ -21,7 +21,7 @@ use windmill_common::{error::JsonResult, utils::StripPath, DB}; /// that depends on windmill-api internals. pub fn workspaced_service() -> Router { windmill_api_flows::flows::workspaced_service() - .route("/get_triggers_count/*path", get(get_triggers_count)) + .route("/get_triggers_count/{*path}", get(get_triggers_count)) } async fn get_triggers_count( diff --git a/backend/windmill-api/src/google.rs b/backend/windmill-api/src/google.rs index dd2a47bca3..fb67f18931 100644 --- a/backend/windmill-api/src/google.rs +++ b/backend/windmill-api/src/google.rs @@ -111,17 +111,16 @@ pub async fn handle_google_ai_chat( let (contents, system_instruction) = openai_messages_to_gemini(&request.messages); - let generation_config = - if request.temperature.is_some() || request.max_tokens.is_some() { - Some(GeminiGenerationConfig { - temperature: request.temperature, - max_output_tokens: request.max_tokens, - response_mime_type: None, - response_schema: None, - }) - } else { - None - }; + let generation_config = if request.temperature.is_some() || request.max_tokens.is_some() { + Some(GeminiGenerationConfig { + temperature: request.temperature, + max_output_tokens: request.max_tokens, + response_mime_type: None, + response_schema: None, + }) + } else { + None + }; let gemini_tools = request.tools.as_ref().map(|tools| { let declarations: Vec = tools @@ -136,10 +135,7 @@ pub async fn handle_google_ai_chat( } }) .collect(); - vec![GeminiTool { - function_declarations: Some(declarations), - google_search: None, - }] + vec![GeminiTool { function_declarations: Some(declarations), google_search: None }] }); let gemini_request = GeminiTextRequest { @@ -184,9 +180,10 @@ async fn handle_streaming( .body(request_body); let request = set_auth(request, api_key, is_vertex); - let response = request.send().await.map_err(|e| { - Error::internal_err(format!("Failed to send request to Gemini API: {}", e)) - })?; + let response = request + .send() + .await + .map_err(|e| Error::internal_err(format!("Failed to send request to Gemini API: {}", e)))?; if let Err(e) = response.error_for_status_ref() { let status = e.status().map(|s| s.to_string()).unwrap_or_default(); @@ -273,9 +270,10 @@ pub async fn handle_google_ai_models( let request = HTTP_CLIENT.get(&endpoint); let request = set_auth(request, api_key, is_vertex); - let response = request.send().await.map_err(|e| { - Error::internal_err(format!("Failed to fetch Gemini models: {}", e)) - })?; + let response = request + .send() + .await + .map_err(|e| Error::internal_err(format!("Failed to fetch Gemini models: {}", e)))?; if let Err(e) = response.error_for_status_ref() { let status = e.status().map(|s| s.to_string()).unwrap_or_default(); @@ -327,9 +325,10 @@ async fn handle_non_streaming( .body(request_body); let request = set_auth(request, api_key, is_vertex); - let response = request.send().await.map_err(|e| { - Error::internal_err(format!("Failed to send request to Gemini API: {}", e)) - })?; + let response = request + .send() + .await + .map_err(|e| Error::internal_err(format!("Failed to send request to Gemini API: {}", e)))?; if let Err(e) = response.error_for_status_ref() { let status = e.status().map(|s| s.to_string()).unwrap_or_default(); @@ -337,9 +336,10 @@ async fn handle_non_streaming( return Err(Error::AIError(format!("{}: {}", status, body))); } - let body = response.bytes().await.map_err(|e| { - Error::internal_err(format!("Failed to read Gemini response body: {}", e)) - })?; + let body = response + .bytes() + .await + .map_err(|e| Error::internal_err(format!("Failed to read Gemini response body: {}", e)))?; let parsed = parse_gemini_response(&body)?; let openai_response = gemini_response_to_openai(&parsed, model); diff --git a/backend/windmill-api/src/group_history.rs b/backend/windmill-api/src/group_history.rs index 0c2c84038d..73162345bc 100644 --- a/backend/windmill-api/src/group_history.rs +++ b/backend/windmill-api/src/group_history.rs @@ -22,7 +22,7 @@ use serde::Serialize; use sqlx::FromRow; pub fn workspaced_service() -> Router { - Router::new().route("/get/:name", get(get_group_permission_history)) + Router::new().route("/get/{name}", get(get_group_permission_history)) } #[derive(Serialize, FromRow)] diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index bde3b81a14..461eb9da5d 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -134,14 +134,14 @@ pub fn workspaced_service() -> Router { Router::new() .route( - "/run/f/*script_path", + "/run/f/{*script_path}", post(run_flow_by_path) .head(|| async { "" }) .layer(cors.clone()) .layer(ce_headers.clone()), ) .route( - "/run/fv/:version", + "/run/fv/{version}", post(run_flow_by_version) .head(|| async { "" }) .layer(cors.clone()) @@ -155,25 +155,25 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run/workflow_as_code/:job_id/:entrypoint", + "/run/workflow_as_code/{job_id}/{entrypoint}", post(run_workflow_as_code) .head(|| async { "" }) .layer(cors.clone()) .layer(ce_headers.clone()), ) .route( - "/restart/f/:job_id", + "/restart/f/{job_id}", post(restart_flow).head(|| async { "" }).layer(cors.clone()), ) .route( - "/run/p/*script_path", + "/run/p/{*script_path}", post(run_script_by_path) .head(|| async { "" }) .layer(cors.clone()) .layer(ce_headers.clone()), ) .route( - "/run_wait_result/p/*script_path", + "/run_wait_result/p/{*script_path}", post(run_wait_result_script_by_path) .get(run_wait_result_job_by_path_get) .head(|| async { "" }) @@ -181,14 +181,14 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_wait_result/h/:hash", + "/run_wait_result/h/{hash}", post(run_wait_result_script_by_hash) .head(|| async { "" }) .layer(cors.clone()) .layer(ce_headers.clone()), ) .route( - "/run_wait_result/f/*script_path", + "/run_wait_result/f/{*script_path}", post(run_wait_result_flow_by_path) .get(run_wait_result_flow_by_path_get) .head(|| async { "" }) @@ -196,7 +196,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_wait_result/fv/:version", + "/run_wait_result/fv/{version}", post(run_wait_result_flow_by_version) .get(run_wait_result_flow_by_version_get) .head(|| async { "" }) @@ -204,7 +204,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_and_stream/f/*script_path", + "/run_and_stream/f/{*script_path}", get(stream_flow_by_path) .post(stream_flow_by_path) .head(|| async { "" }) @@ -212,7 +212,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_and_stream/fv/:version", + "/run_and_stream/fv/{version}", get(stream_flow_by_version) .post(stream_flow_by_version) .head(|| async { "" }) @@ -220,7 +220,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_and_stream/p/*script_path", + "/run_and_stream/p/{*script_path}", get(stream_script_by_path) .post(stream_script_by_path) .head(|| async { "" }) @@ -228,7 +228,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_and_stream/h/:hash", + "/run_and_stream/h/{hash}", get(stream_script_by_hash) .post(stream_script_by_hash) .head(|| async { "" }) @@ -236,7 +236,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run/h/:hash", + "/run/h/{hash}", post(run_job_by_hash) .head(|| async { "" }) .layer(cors.clone()) @@ -245,10 +245,10 @@ pub fn workspaced_service() -> Router { .route("/run/preview", post(run_preview_script)) .route("/run_inline/preview", post(run_inline_preview_script)) .route( - "/run_inline/p/*script_path", + "/run_inline/p/{*script_path}", post(run_inline_script_by_path), ) - .route("/run_inline/h/:hash", post(run_inline_script_by_hash)) + .route("/run_inline/h/{hash}", post(run_inline_script_by_hash)) .route( "/run_wait_result/preview", post(run_wait_result_preview_script), @@ -257,7 +257,7 @@ pub fn workspaced_service() -> Router { "/run/preview_bundle", post(run_bundle_preview_script).layer(axum::extract::DefaultBodyLimit::disable()), ) - .route("/add_batch_jobs/:n", post(add_batch_jobs)) + .route("/add_batch_jobs/{n}", post(add_batch_jobs)) .route("/run/preview_flow", post(run_preview_flow_job)) .route( "/run_wait_result/preview_flow", @@ -280,8 +280,8 @@ pub fn workspaced_service() -> Router { ) .route("/queue/count", get(count_queue_jobs)) .route("/queue/list_filtered_uuids", get(list_filtered_uuids)) - .route("/queue/position/:timestamp", get(get_queue_position)) - .route("/queue/scheduled_for/:id", get(get_scheduled_for)) + .route("/queue/position/{timestamp}", get(get_queue_position)) + .route("/queue/scheduled_for/{id}", get(get_scheduled_for)) .route("/queue/cancel_selection", post(cancel_selection)) .route("/completed/count", get(count_completed_jobs)) .route("/completed/count_jobs", get(count_completed_jobs_detail)) @@ -299,49 +299,49 @@ pub fn workspaced_service() -> Router { ) .route("/delete", post(crate::jobs_export::delete_jobs)) .route( - "/completed/get/:id", + "/completed/get/{id}", get(get_completed_job).layer(cors.clone()), ) .route( - "/completed/get_result/:id", + "/completed/get_result/{id}", get(get_completed_job_result).layer(cors.clone()), ) .route( - "/completed/get_result_maybe/:id", + "/completed/get_result_maybe/{id}", get(get_completed_job_result_maybe).layer(cors.clone()), ) .route( - "/completed/get_timing/:id", + "/completed/get_timing/{id}", get(get_completed_job_timing).layer(cors.clone()), ) .route( - "/completed/delete/:id", + "/completed/delete/{id}", post(delete_completed_job).layer(cors.clone()), ) .route( - "/flow/resume/:id", + "/flow/resume/{id}", post(resume_suspended_flow_as_owner).layer(cors.clone()), ) .route( - "/job_signature/:job_id/:resume_id", + "/job_signature/{job_id}/{resume_id}", get(create_job_signature).layer(cors.clone()), ) .route( - "/flow/user_states/:job_id/:key", + "/flow/user_states/{job_id}/{key}", get(get_flow_user_state) .post(set_flow_user_state) .layer(cors.clone()), ) .route( - "/resume_urls/:job_id/:resume_id", + "/resume_urls/{job_id}/{resume_id}", get(get_resume_urls).layer(cors.clone()), ) .route( - "/result_by_id/:job_id/:node_id", + "/result_by_id/{job_id}/{node_id}", get(get_result_by_id).layer(cors.clone()), ) .route( - "/flow_env_by_flow_job_id/:flow_job_id/:var_name", + "/flow_env_by_flow_job_id/{flow_job_id}/{var_name}", get(get_flow_env_by_flow_job_id).layer(cors.clone()), ) .route("/run/dependencies", post(run_dependencies_job)) @@ -350,59 +350,59 @@ pub fn workspaced_service() -> Router { "/send_email_with_instance_smtp", post(send_email_with_instance_smtp), ) - .route("/get_otel_traces/:id", get(get_otel_traces)) + .route("/get_otel_traces/{id}", get(get_otel_traces)) } pub fn workspace_unauthed_service() -> Router { Router::new() .route( - "/resume/:job_id/:resume_id/:secret", + "/resume/{job_id}/{resume_id}/{secret}", get(resume_suspended_job), ) .route( - "/resume/:job_id/:resume_id/:secret", + "/resume/{job_id}/{resume_id}/{secret}", post(resume_suspended_job), ) .route( - "/cancel/:job_id/:resume_id/:secret", + "/cancel/{job_id}/{resume_id}/{secret}", get(cancel_suspended_job), ) .route( - "/cancel/:job_id/:resume_id/:secret", + "/cancel/{job_id}/{resume_id}/{secret}", post(cancel_suspended_job), ) .route( - "/get_flow/:job_id/:resume_id/:secret", + "/get_flow/{job_id}/{resume_id}/{secret}", get(get_suspended_job_flow), ) - .route("/get_root_job_id/:id", get(get_root_job)) - .route("/get/:id", get(get_job)) - .route("/get_logs/:id", get(get_job_logs)) + .route("/get_root_job_id/{id}", get(get_root_job)) + .route("/get/{id}", get(get_job)) + .route("/get_logs/{id}", get(get_job_logs)) .route( - "/get_completed_logs_tail/:id", + "/get_completed_logs_tail/{id}", get(get_completed_job_logs_tail), ) - .route("/get_args/:id", get(get_args)) + .route("/get_args/{id}", get(get_args)) .route("/queue/get_started_at_by_ids", post(get_started_at_by_ids)) - .route("/get_flow_debug_info/:id", get(get_flow_job_debug_info)) - .route("/completed/get/:id", get(get_completed_job)) - .route("/completed/get_result/:id", get(get_completed_job_result)) + .route("/get_flow_debug_info/{id}", get(get_flow_job_debug_info)) + .route("/completed/get/{id}", get(get_completed_job)) + .route("/completed/get_result/{id}", get(get_completed_job_result)) .route( - "/completed/get_result_maybe/:id", + "/completed/get_result_maybe/{id}", get(get_completed_job_result_maybe), ) - .route("/completed/get_timing/:id", get(get_completed_job_timing)) - .route("/getupdate/:id", get(get_job_update)) - .route("/getupdate_sse/:id", get(get_job_update_sse)) - .route("/get_log_file/*file_path", get(get_log_file)) - .route("/queue/cancel/:id", post(cancel_job_api)) + .route("/completed/get_timing/{id}", get(get_completed_job_timing)) + .route("/getupdate/{id}", get(get_job_update)) + .route("/getupdate_sse/{id}", get(get_job_update_sse)) + .route("/get_log_file/{*file_path}", get(get_log_file)) + .route("/queue/cancel/{id}", post(cancel_job_api)) .route( - "/queue/cancel_persistent/*script_path", + "/queue/cancel_persistent/{*script_path}", post(cancel_persistent_script_api), ) - .route("/queue/force_cancel/:id", post(force_cancel)) - .route("/flow/resume_suspended/:job_id", post(resume_suspended)) - .route("/flow/approval_info/:job_id", get(get_approval_info)) + .route("/queue/force_cancel/{id}", post(force_cancel)) + .route("/flow/resume_suspended/{job_id}", post(resume_suspended)) + .route("/flow/approval_info/{job_id}", get(get_approval_info)) } pub fn global_root_service() -> Router { diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 15a674ea37..450713e614 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -37,6 +37,7 @@ use axum::body::Body; use axum::extract::DefaultBodyLimit; use axum::http::HeaderValue; use axum::response::Response; +use axum::serve::ListenerExt; use axum::{middleware::from_extractor, routing::get, routing::post, Extension, Json, Router}; use db::DB; use tokio::task::JoinHandle; @@ -527,7 +528,7 @@ pub async fn run_server( "/api", Router::new() .nest( - "/w/:workspace_id", + "/w/{workspace_id}", Router::new() // Reordered alphabetically .nest("/acls", granular_acls::workspaced_service()) @@ -640,7 +641,7 @@ pub async fn run_server( .nest("/ai", ai::global_service()) .nest("/inkeep", inkeep_oss::global_service()) .nest("/indexer", indexer_oss::management_service()) - .nest("/mcp/w/:workspace_id/list_tools", mcp_list_tools_service) + .nest("/mcp/w/{workspace_id}/list_tools", mcp_list_tools_service) .nest("/health/detailed", health::detailed_service()) .nest( "/saml", @@ -659,7 +660,7 @@ pub async fn run_server( .route_layer(from_extractor::()) // Workspace-scoped OAuth endpoints that don't require authentication // (authorize and token are called by MCP client before user is authenticated) - .nest("/w/:workspace_id/mcp/oauth/server", { + .nest("/w/{workspace_id}/mcp/oauth/server", { #[cfg(feature = "mcp")] { mcp::oauth_server::workspaced_unauthed_service() @@ -680,7 +681,7 @@ pub async fn run_server( }) .nest("/jobs", jobs::global_root_service()) .nest( - "/srch/w/:workspace_id/index", + "/srch/w/{workspace_id}/index", indexer_oss::workspaced_service(), ) .nest("/srch/index", indexer_oss::global_service()) @@ -710,19 +711,19 @@ pub async fn run_server( } }) .nest( - "/w/:workspace_id/apps_u", + "/w/{workspace_id}/apps_u", apps::unauthed_service() .layer(from_extractor::()) .layer(cors.clone()), ) .layer(from_extractor::()) - // Deprecated, here for backwards compatibility: user should use /mcp/w/:workspace_id/mcp instead + // Deprecated, here for backwards compatibility: user should use /mcp/w/{workspace_id}/mcp instead .nest( - "/mcp/w/:workspace_id/sse", + "/mcp/w/{workspace_id}/sse", mcp_router.clone().layer(cors.clone()), ) .nest( - "/mcp/w/:workspace_id/mcp", + "/mcp/w/{workspace_id}/mcp", mcp_router.clone().layer(cors.clone()), ) .nest("/mcp/gateway", gateway_mcp_router.layer(cors.clone())) @@ -745,7 +746,7 @@ pub async fn run_server( Router::new() } }) - .nest("/w/:workspace_id/agent_workers", { + .nest("/w/{workspace_id}/agent_workers", { #[cfg(feature = "agent_worker_server")] { agent_workers_router @@ -762,7 +763,7 @@ pub async fn run_server( } }) .nest( - "/w/:workspace_id/jobs_u", + "/w/{workspace_id}/jobs_u", jobs::workspace_unauthed_service().layer(cors.clone()), ) .route("/slack", post(slack_approvals::slack_app_callback_handler)) @@ -778,14 +779,14 @@ pub async fn run_server( } }) .route( - "/w/:workspace_id/jobs/slack_approval/:job_id", + "/w/{workspace_id}/jobs/slack_approval/{job_id}", get(slack_approvals::request_slack_approval), ) .route( - "/w/:workspace_id/jobs/teams_approval/:job_id", + "/w/{workspace_id}/jobs/teams_approval/{job_id}", get(teams_approvals_oss::request_teams_approval), ) - .nest("/w/:workspace_id/github_app", { + .nest("/w/{workspace_id}/github_app", { #[cfg(feature = "enterprise")] { git_sync_oss::workspaced_service() @@ -804,14 +805,14 @@ pub async fn run_server( Router::new() }) .nest( - "/w/:workspace_id/resources_u", + "/w/{workspace_id}/resources_u", public_service().layer(cors.clone()), ) .nest( - "/w/:workspace_id/capture_u", + "/w/{workspace_id}/capture_u", capture::workspaced_unauthed_service().layer(cors.clone()), ) - .nest("/w/:workspace_id/s3_proxy", { + .nest("/w/{workspace_id}/s3_proxy", { s3_proxy_oss::workspaced_unauthed_service() }) .nest( @@ -856,7 +857,7 @@ pub async fn run_server( Router::new() } }) - .nest("/gcp/w/:workspace_id", { + .nest("/gcp/w/{workspace_id}", { #[cfg(all( feature = "enterprise", feature = "gcp_trigger", @@ -883,10 +884,10 @@ pub async fn run_server( .route("/openapi.json", get(openapi_json)), ) // Clients must use workspace-scoped OAuth metadata at: - // /.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server + // /.well-known/oauth-authorization-server/api/w/{workspace_id}/mcp/oauth/server // This is discovered via /.well-known/oauth-protected-resource?workspace_id=... .route( - "/.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server", + "/.well-known/oauth-authorization-server/api/w/{workspace_id}/mcp/oauth/server", { #[cfg(feature = "mcp")] { @@ -898,9 +899,9 @@ pub async fn run_server( } }, ) - // RFC 9728 path-based discovery: /.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp + // RFC 9728 path-based discovery: /.well-known/oauth-protected-resource/api/mcp/w/{workspace_id}/mcp .route( - "/.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp", + "/.well-known/oauth-protected-resource/api/mcp/w/{workspace_id}/mcp", { #[cfg(feature = "mcp")] { @@ -976,7 +977,10 @@ pub async fn run_server( if let Some(name) = name.as_ref() { tracing::info!("server starting for name={name}"); } - let server = axum::serve(listener, app.into_make_service()).tcp_nodelay(!server_mode); + let listener = listener.tap_io(move |tcp_stream| { + let _ = tcp_stream.set_nodelay(!server_mode); + }); + let server = axum::serve(listener, app.into_make_service()); tracing::info!( instance = %*INSTANCE_NAME, diff --git a/backend/windmill-api/src/raw_apps.rs b/backend/windmill-api/src/raw_apps.rs index e331aa1176..ed746b8770 100644 --- a/backend/windmill-api/src/raw_apps.rs +++ b/backend/windmill-api/src/raw_apps.rs @@ -27,7 +27,7 @@ use windmill_common::{ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_apps)) - .route("/get_data/:version/*path", get(get_data)) + .route("/get_data/{version}/{*path}", get(get_data)) } #[derive(FromRow, Deserialize, Serialize)] diff --git a/backend/windmill-api/src/resources.rs b/backend/windmill-api/src/resources.rs index f4bcfc621b..ee26ce758e 100644 --- a/backend/windmill-api/src/resources.rs +++ b/backend/windmill-api/src/resources.rs @@ -10,7 +10,7 @@ pub fn workspaced_service() -> Router { #[cfg(feature = "mcp")] use crate::mcp_tools::get_mcp_tools; #[cfg(feature = "mcp")] - let router = router.route("/mcp_tools/*path", get(get_mcp_tools)); + let router = router.route("/mcp_tools/{*path}", get(get_mcp_tools)); router } diff --git a/backend/windmill-api/src/scim_oss.rs b/backend/windmill-api/src/scim_oss.rs index 5210411466..845c854960 100644 --- a/backend/windmill-api/src/scim_oss.rs +++ b/backend/windmill-api/src/scim_oss.rs @@ -11,9 +11,7 @@ pub use crate::scim_ee::*; */ #[cfg(not(feature = "private"))] -use axum::{middleware::Next, response::Response, routing::get, Router}; -#[cfg(not(feature = "private"))] -use hyper::Request; +use axum::{extract::Request, middleware::Next, response::Response, routing::get, Router}; #[cfg(not(feature = "private"))] pub fn global_service() -> Router { @@ -26,7 +24,7 @@ pub async fn ee() -> String { } #[cfg(not(feature = "private"))] -pub async fn has_scim_token(_request: Request, _next: Next) -> Response { +pub async fn has_scim_token(_request: Request, _next: Next) -> Response { //Not implemented in open-source version todo!() } diff --git a/backend/windmill-api/src/scripts.rs b/backend/windmill-api/src/scripts.rs index abd0b4201b..f9dd17fa83 100644 --- a/backend/windmill-api/src/scripts.rs +++ b/backend/windmill-api/src/scripts.rs @@ -21,7 +21,7 @@ use windmill_common::{error::JsonResult, utils::StripPath, DB}; /// that depends on windmill-api internals. pub fn workspaced_service() -> Router { windmill_api_scripts::scripts::workspaced_service() - .route("/get_triggers_count/*path", get(get_triggers_count)) + .route("/get_triggers_count/{*path}", get(get_triggers_count)) } async fn get_triggers_count( diff --git a/backend/windmill-api/src/service_logs.rs b/backend/windmill-api/src/service_logs.rs index c83bb21f2c..2e03a5a104 100644 --- a/backend/windmill-api/src/service_logs.rs +++ b/backend/windmill-api/src/service_logs.rs @@ -20,7 +20,7 @@ use crate::db::{ApiAuthed, DB}; pub fn global_service() -> Router { Router::new() .route("/list_files", get(list_files)) - .route("/get_log_file/*path", get(get_log_file)) + .route("/get_log_file/{*path}", get(get_log_file)) } use axum::extract::Path; diff --git a/backend/windmill-api/src/trash.rs b/backend/windmill-api/src/trash.rs index 8e7b7f0ad7..a4bc105238 100644 --- a/backend/windmill-api/src/trash.rs +++ b/backend/windmill-api/src/trash.rs @@ -17,9 +17,9 @@ use crate::db::{ApiAuthed, DB}; pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_trash)) - .route("/get/:id", get(get_trash_item)) - .route("/restore/:id", post(restore_trash_item)) - .route("/delete/:id", delete(permanently_delete_item)) + .route("/get/{id}", get(get_trash_item)) + .route("/restore/{id}", post(restore_trash_item)) + .route("/delete/{id}", delete(permanently_delete_item)) .route("/empty", post(empty_trash)) } diff --git a/backend/windmill-api/src/triggers/handler.rs b/backend/windmill-api/src/triggers/handler.rs index 78715e2661..f798b4d706 100644 --- a/backend/windmill-api/src/triggers/handler.rs +++ b/backend/windmill-api/src/triggers/handler.rs @@ -108,11 +108,11 @@ pub fn generate_trigger_routers() -> Router { router = router .route( - "/trigger/:trigger_kind/resume_suspended_trigger_jobs/*trigger_path", + "/trigger/{trigger_kind}/resume_suspended_trigger_jobs/{*trigger_path}", post(resume_suspended_trigger_jobs), ) .route( - "/trigger/:trigger_kind/cancel_suspended_trigger_jobs/*trigger_path", + "/trigger/{trigger_kind}/cancel_suspended_trigger_jobs/{*trigger_path}", post(cancel_suspended_trigger_jobs), ); } diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 8af10bda18..ccde55bab1 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -95,7 +95,7 @@ async fn conditional_cors_middleware( pub fn http_route_trigger_handler() -> Router { Router::new() .route( - "/*path", + "/{*path}", get(route_job) .post(route_job) .delete(route_job) 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 7df1e9f200..f8460f42e2 100644 --- a/backend/windmill-api/src/triggers/http/http_trigger_args.rs +++ b/backend/windmill-api/src/triggers/http/http_trigger_args.rs @@ -25,8 +25,7 @@ use crate::{ pub struct RawHttpTriggerArgs(pub RawWebhookArgs); -#[axum::async_trait] -impl FromRequest for RawHttpTriggerArgs +impl FromRequest for RawHttpTriggerArgs where S: Send + Sync, { diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 498e93e61a..aec6080bfd 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -35,9 +35,9 @@ use windmill_common::{ pub fn global_service() -> Router { windmill_api_users::users::global_service() .route("/setpassword", post(set_password)) - .route("/set_password_of/:user", post(set_password_of_user)) + .route("/set_password_of/{user}", post(set_password_of_user)) .route("/create", post(create_user)) - .route("/rename/:user", post(rename_user)) + .route("/rename/{user}", post(rename_user)) .route("/onboarding", post(submit_onboarding_data)) } diff --git a/backend/windmill-api/src/workspace_dependencies.rs b/backend/windmill-api/src/workspace_dependencies.rs index e5c9377b01..194cc1f0d5 100644 --- a/backend/windmill-api/src/workspace_dependencies.rs +++ b/backend/windmill-api/src/workspace_dependencies.rs @@ -24,9 +24,9 @@ pub fn workspaced_service() -> Router { Router::new() .route("/create", post(create)) .route("/list", get(list)) - .route("/archive/:language", post(archive)) - .route("/get_latest/:language", get(get_latest)) - .route("/delete/:language", post(delete)) + .route("/archive/{language}", post(archive)) + .route("/get_latest/{language}", get(get_latest)) + .route("/delete/{language}", post(delete)) } #[axum::debug_handler] diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 3438ad97af..6d644a7946 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -63,7 +63,7 @@ pub fn workspaced_service() -> Router { .route("/get_copilot_info", get(get_copilot_info)) .route("/critical_alerts", get(get_critical_alerts)) .route( - "/critical_alerts/:id/acknowledge", + "/critical_alerts/{id}/acknowledge", post(acknowledge_critical_alert), ) .route( diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index cd6e1a8100..60eb006a97 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -513,13 +513,13 @@ pub fn service_routes(handler: T) -> Router { let standard_routes = Router::new() .route("/create", post(create_native_trigger::)) .route("/list", get(list_native_triggers_handler::)) - .route("/get/:external_id", get(get_native_trigger_handler::)) + .route("/get/{external_id}", get(get_native_trigger_handler::)) .route( - "/update/:external_id", + "/update/{external_id}", post(update_native_trigger_handler::), ) .route( - "/delete/:external_id", + "/delete/{external_id}", delete(delete_native_trigger_handler::), ); diff --git a/backend/windmill-native-triggers/src/workspace_integrations.rs b/backend/windmill-native-triggers/src/workspace_integrations.rs index 87d40d5b05..9453f033ab 100644 --- a/backend/windmill-native-triggers/src/workspace_integrations.rs +++ b/backend/windmill-native-triggers/src/workspace_integrations.rs @@ -964,22 +964,22 @@ async fn generate_instance_connect_url( pub fn workspaced_service() -> Router { let router = Router::new() .route("/list", get(list_integrations)) - .route("/:service_name/exists", get(integration_exist)) - .route("/:service_name/create", post(create_workspace_integration)) + .route("/{service_name}/exists", get(integration_exist)) + .route("/{service_name}/create", post(create_workspace_integration)) .route( - "/:service_name/generate_connect_url", + "/{service_name}/generate_connect_url", post(generate_connect_url), ) .route( - "/:service_name/instance_sharing_available", + "/{service_name}/instance_sharing_available", get(check_instance_sharing_available), ) .route( - "/:service_name/generate_instance_connect_url", + "/{service_name}/generate_instance_connect_url", post(generate_instance_connect_url), ) - .route("/:service_name/delete", delete(delete_integration)) - .route("/:service_name/callback", post(oauth_callback)); + .route("/{service_name}/delete", delete(delete_integration)) + .route("/{service_name}/callback", post(oauth_callback)); Router::new().nest("/integrations", router) } diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index d614837237..eff53be481 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -516,7 +516,7 @@ pub async fn exchange_code( }; let csrf_state = cookies .get(name) - .map(|x| x.value().to_string()) + .map(|x| x.value_trimmed().to_string()) .unwrap_or("".to_string()); if callback.state != csrf_state { return Err(error::Error::BadRequest("csrf did not match".to_string())); diff --git a/backend/windmill-object-store/src/lib.rs b/backend/windmill-object-store/src/lib.rs index 4caf67f348..e8d33da2e2 100644 --- a/backend/windmill-object-store/src/lib.rs +++ b/backend/windmill-object-store/src/lib.rs @@ -5,13 +5,13 @@ use std::collections::HashMap; use quick_cache::sync::Cache; use windmill_common::error::{self}; +#[cfg(feature = "parquet")] +use async_trait::async_trait; #[cfg(feature = "parquet")] use aws_config::{default_provider::credentials::DefaultCredentialsChain, Region}; #[cfg(feature = "parquet")] use aws_sdk_sts::config::ProvideCredentials; #[cfg(feature = "parquet")] -use axum::async_trait; -#[cfg(feature = "parquet")] use bytes::Bytes; #[cfg(feature = "parquet")] use chrono::{DateTime, Utc}; diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 102bab8585..c7a14187c8 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -55,26 +55,26 @@ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_resources)) .route("/list_search", get(list_search_resources)) - .route("/list_names/:type", get(list_names)) - .route("/get/*path", get(get_resource)) - .route("/exists/*path", get(exists_resource)) - .route("/get_value/*path", get(get_resource_value)) + .route("/list_names/{type}", get(list_names)) + .route("/get/{*path}", get(get_resource)) + .route("/exists/{*path}", get(exists_resource)) + .route("/get_value/{*path}", get(get_resource_value)) .route( - "/get_value_interpolated/*path", + "/get_value_interpolated/{*path}", get(get_resource_value_interpolated), ) - .route("/update/*path", post(update_resource)) - .route("/update_value/*path", post(update_resource_value)) - .route("/delete/*path", delete(delete_resource)) + .route("/update/{*path}", post(update_resource)) + .route("/update_value/{*path}", post(update_resource_value)) + .route("/delete/{*path}", delete(delete_resource)) .route("/delete_bulk", delete(delete_resources_bulk)) .route("/create", post(create_resource)) - .route("/git_commit_hash/*path", get(get_git_commit_hash)) + .route("/git_commit_hash/{*path}", get(get_git_commit_hash)) .route("/type/list", get(list_resource_types)) .route("/type/listnames", get(list_resource_types_names)) - .route("/type/get/:name", get(get_resource_type)) - .route("/type/exists/:name", get(exists_resource_type)) - .route("/type/update/:name", post(update_resource_type)) - .route("/type/delete/:name", delete(delete_resource_type)) + .route("/type/get/{name}", get(get_resource_type)) + .route("/type/exists/{name}", get(exists_resource_type)) + .route("/type/update/{name}", post(update_resource_type)) + .route("/type/delete/{name}", delete(delete_resource_type)) .route( "/file_resource_type_to_file_ext_map", get(file_resource_ext_to_resource_type), @@ -83,7 +83,7 @@ pub fn workspaced_service() -> Router { } pub fn public_service() -> Router { - Router::new().route("/custom_component/:name", get(custom_component)) + Router::new().route("/custom_component/{name}", get(custom_component)) } #[derive(FromRow, Serialize, Deserialize)] diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 964f81809c..c893f61cb0 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -54,11 +54,11 @@ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_variables)) .route("/list_contextual", get(list_contextual_variables)) - .route("/get/*path", get(get_variable)) - .route("/get_value/*path", get(get_value)) - .route("/exists/*path", get(exists_variable)) - .route("/update/*path", post(update_variable)) - .route("/delete/*path", delete(delete_variable)) + .route("/get/{*path}", get(get_variable)) + .route("/get_value/{*path}", get(get_value)) + .route("/exists/{*path}", get(exists_variable)) + .route("/update/{*path}", post(update_variable)) + .route("/delete/{*path}", delete(delete_variable)) .route("/delete_bulk", delete(delete_variables_bulk)) .route("/create", post(create_variable)) .route("/encrypt", post(encrypt_value)) diff --git a/backend/windmill-test-utils/Cargo.toml b/backend/windmill-test-utils/Cargo.toml index d1729a7511..18cf8cde85 100644 --- a/backend/windmill-test-utils/Cargo.toml +++ b/backend/windmill-test-utils/Cargo.toml @@ -35,5 +35,6 @@ tokio.workspace = true uuid.workspace = true chrono.workspace = true axum.workspace = true +async-trait.workspace = true anyhow.workspace = true tracing.workspace = true diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index d9e22eaa84..6a15ffa5ae 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -478,7 +478,7 @@ pub async fn completed_job(uuid: Uuid, db: &Pool) -> CompletedJob { .unwrap() } -#[axum::async_trait(?Send)] +#[async_trait::async_trait(?Send)] pub trait StreamFind: futures::Stream + Unpin + Sized { async fn find(self, item: &Self::Item) -> Option where diff --git a/backend/windmill-trigger-email/src/handler_oss.rs b/backend/windmill-trigger-email/src/handler_oss.rs index 9579bee274..b6cac94cc2 100644 --- a/backend/windmill-trigger-email/src/handler_oss.rs +++ b/backend/windmill-trigger-email/src/handler_oss.rs @@ -8,7 +8,7 @@ pub use super::handler_ee::*; #[cfg(not(feature = "private"))] use { super::EmailTrigger, - axum::async_trait, + async_trait::async_trait, sqlx::PgConnection, windmill_api_auth::ApiAuthed, windmill_common::{ diff --git a/backend/windmill-trigger-gcp/src/handler_oss.rs b/backend/windmill-trigger-gcp/src/handler_oss.rs index b259c87834..5cf0f17c02 100644 --- a/backend/windmill-trigger-gcp/src/handler_oss.rs +++ b/backend/windmill-trigger-gcp/src/handler_oss.rs @@ -5,7 +5,7 @@ pub use super::handler_ee::*; #[cfg(not(feature = "private"))] use { super::GcpTrigger, - axum::async_trait, + async_trait::async_trait, sqlx::PgConnection, windmill_api_auth::ApiAuthed, windmill_common::{ diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index f6ca4739da..46e278ca5b 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -2,7 +2,8 @@ use super::{ validate_authentication_method, HttpConfig, HttpConfigRequest, HttpMethod, HttpTrigger, RouteExists, ROUTE_PATH_KEY_RE, VALID_ROUTE_PATH_RE, }; -use axum::{async_trait, extract::Path, routing::post, Extension, Json, Router}; +use async_trait::async_trait; +use axum::{extract::Path, routing::post, Extension, Json, Router}; use http::StatusCode; use sqlx::PgConnection; use std::collections::HashSet; diff --git a/backend/windmill-trigger-kafka/src/handler_oss.rs b/backend/windmill-trigger-kafka/src/handler_oss.rs index ace4b87d0a..2e1cb8bfb4 100644 --- a/backend/windmill-trigger-kafka/src/handler_oss.rs +++ b/backend/windmill-trigger-kafka/src/handler_oss.rs @@ -8,7 +8,7 @@ pub use super::handler_ee::*; #[cfg(not(feature = "private"))] use { super::KafkaTrigger, - axum::async_trait, + async_trait::async_trait, sqlx::PgConnection, windmill_api_auth::ApiAuthed, windmill_common::{ diff --git a/backend/windmill-trigger-mqtt/src/handler.rs b/backend/windmill-trigger-mqtt/src/handler.rs index 6edcfbabc3..49fb701241 100644 --- a/backend/windmill-trigger-mqtt/src/handler.rs +++ b/backend/windmill-trigger-mqtt/src/handler.rs @@ -1,4 +1,4 @@ -use axum::async_trait; +use async_trait::async_trait; use itertools::Itertools; use sqlx::{types::Json as SqlxJson, PgConnection}; use windmill_api_auth::ApiAuthed; diff --git a/backend/windmill-trigger-nats/src/handler_oss.rs b/backend/windmill-trigger-nats/src/handler_oss.rs index b00d973621..f322335cb8 100644 --- a/backend/windmill-trigger-nats/src/handler_oss.rs +++ b/backend/windmill-trigger-nats/src/handler_oss.rs @@ -8,7 +8,7 @@ use windmill_trigger::TriggerData; #[cfg(not(feature = "private"))] use { super::NatsTrigger, - axum::async_trait, + async_trait::async_trait, sqlx::PgConnection, windmill_api_auth::ApiAuthed, windmill_common::{ diff --git a/backend/windmill-trigger-postgres/src/handler.rs b/backend/windmill-trigger-postgres/src/handler.rs index cbb149af52..dc2f4776fd 100644 --- a/backend/windmill-trigger-postgres/src/handler.rs +++ b/backend/windmill-trigger-postgres/src/handler.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; +use async_trait::async_trait; use axum::{ - async_trait, extract::Path, routing::{delete, get, post}, Extension, Json, Router, @@ -282,10 +282,10 @@ impl TriggerCrud for PostgresTrigger { fn additional_routes(&self) -> Router { Router::new() - .route("/get_template_script/:id", get(get_template_script)) + .route("/get_template_script/{id}", get(get_template_script)) .route("/create_template_script", post(create_template_script)) .route( - "/is_valid_postgres_configuration/*path", + "/is_valid_postgres_configuration/{*path}", get(is_database_in_logical_level), ) .nest("/publication", publication_service()) @@ -296,25 +296,31 @@ impl TriggerCrud for PostgresTrigger { fn publication_service() -> Router { Router::new() - .route("/get/:publication_name/*path", get(get_publication_info)) - .route("/create/:publication_name/*path", post(create_publication)) - .route("/update/:publication_name/*path", post(alter_publication)) + .route("/get/{publication_name}/{*path}", get(get_publication_info)) .route( - "/delete/:publication_name/*path", + "/create/{publication_name}/{*path}", + post(create_publication), + ) + .route( + "/update/{publication_name}/{*path}", + post(alter_publication), + ) + .route( + "/delete/{publication_name}/{*path}", delete(delete_publication), ) - .route("/list/*path", get(list_database_publication)) + .route("/list/{*path}", get(list_database_publication)) } fn slot_service() -> Router { Router::new() - .route("/list/*path", get(list_slot_name)) - .route("/create/*path", post(create_slot)) - .route("/delete/*path", delete(drop_slot_name)) + .route("/list/{*path}", get(list_slot_name)) + .route("/create/{*path}", post(create_slot)) + .route("/delete/{*path}", delete(drop_slot_name)) } fn postgres_service() -> Router { - Router::new().route("/version/*path", get(get_postgres_version)) + Router::new().route("/version/{*path}", get(get_postgres_version)) } async fn check_if_logical_replication_slot_exist( diff --git a/backend/windmill-trigger-sqs/src/handler_oss.rs b/backend/windmill-trigger-sqs/src/handler_oss.rs index 90396e9994..fc72159e24 100644 --- a/backend/windmill-trigger-sqs/src/handler_oss.rs +++ b/backend/windmill-trigger-sqs/src/handler_oss.rs @@ -5,7 +5,7 @@ pub use super::handler_ee::*; #[cfg(not(feature = "private"))] use { super::SqsTrigger, - axum::async_trait, + async_trait::async_trait, sqlx::PgConnection, windmill_api_auth::ApiAuthed, windmill_common::{ diff --git a/backend/windmill-trigger-websocket/src/handler.rs b/backend/windmill-trigger-websocket/src/handler.rs index fd0080950d..8411bb0246 100644 --- a/backend/windmill-trigger-websocket/src/handler.rs +++ b/backend/windmill-trigger-websocket/src/handler.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use axum::async_trait; +use async_trait::async_trait; use itertools::Itertools; use serde_json::value::RawValue; use sqlx::{types::Json as SqlxJson, PgConnection}; diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 478f20811d..16f095a903 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -374,11 +374,11 @@ pub fn trigger_routes() -> Router { let mut router = Router::new() .route("/create", post(create_trigger::)) .route("/list", get(list_triggers::)) - .route("/get/*path", get(get_trigger::)) - .route("/update/*path", post(update_trigger::)) - .route("/delete/*path", delete(delete_trigger::)) - .route("/exists/*path", get(exists_trigger::)) - .route("/setmode/*path", post(set_trigger_mode::)); + .route("/get/{*path}", get(get_trigger::)) + .route("/update/{*path}", post(update_trigger::)) + .route("/delete/{*path}", delete(delete_trigger::)) + .route("/exists/{*path}", get(exists_trigger::)) + .route("/setmode/{*path}", post(set_trigger_mode::)); if T::SUPPORTS_TEST_CONNECTION { router = router.route("/test", post(test_connection::)); From d06b42613f73c4a7b31c990be22b0c97efab2666 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:35:28 +0100 Subject: [PATCH 066/153] feat(cli): generate commented wmill.yaml and add config reference command (#8546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: generate commented wmill.yaml template and add config reference command Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add missing options to config reference (promotion, skipBranchValidation, commonSpecificItems) Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: generate YAML template from CONFIG_REFERENCE instead of handwritten string Co-Authored-By: Claude Opus 4.6 (1M context) * fix: preserve YAML comments when binding workspace profile during init Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: simplify to `wmill config` and reorder table columns Co-Authored-By: Claude Opus 4.6 (1M context) * feat: generate JSON Schema for wmill.yaml editor autocomplete and validation Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove redundant templateValue fields and make specificItemsSchema data-driven Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: use native JSON Schema types in CONFIG_REFERENCE, strip non-schema keys for generation Eliminates typeToJsonSchema, specificItemsSchema, codebaseItemSchema, branchConfigSchema, and the complex generateJsonSchema body. Each CONFIG_REFERENCE entry is now a JSON Schema property with extra metadata. Schema generation just iterates and strips non-schema keys. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove typeLabel and displayType — use schema types directly Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove hidden entries, auto-expand nested schemas in reference table Sub-fields (codebases[], gitBranches..*) are now derived from the parent's inline schema instead of being maintained as duplicate hidden entries. Removes 29 entries and the hidden field entirely. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use console.log for JSON output and quote YAML-special branch names Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate system prompts to include new config command Co-Authored-By: Claude Opus 4.6 (1M context) * fix: review feedback + add tests for template, schema, and config reference - Use console.log for --json output (no ANSI escape codes) - Quote branch names with YAML-special characters - Add 28 tests covering template generation, JSON Schema validation, config reference formatting, and CONFIG_REFERENCE integrity Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add generate-schema script and commit wmill.schema.json to repo Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove schema.json generation from wmill init Co-Authored-By: Claude Opus 4.6 (1M context) * fix: eliminate read-back cycle, harden yamlKey, fix triple negation Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/generate-schema.ts | 16 + cli/src/commands/config/config.ts | 26 ++ cli/src/commands/init/init.ts | 87 ++-- cli/src/commands/init/template.ts | 395 ++++++++++++++++ cli/src/guidance/skills.ts | 7 + cli/src/main.ts | 3 + cli/test/init_template.test.ts | 244 ++++++++++ cli/wmill.schema.json | 439 ++++++++++++++++++ .../auto-generated/cli/cli-commands.md | 7 + system_prompts/auto-generated/prompts.ts | 7 + .../skills/cli-commands/SKILL.md | 7 + 11 files changed, 1180 insertions(+), 58 deletions(-) create mode 100644 cli/generate-schema.ts create mode 100644 cli/src/commands/config/config.ts create mode 100644 cli/src/commands/init/template.ts create mode 100644 cli/test/init_template.test.ts create mode 100644 cli/wmill.schema.json diff --git a/cli/generate-schema.ts b/cli/generate-schema.ts new file mode 100644 index 0000000000..a5925968d6 --- /dev/null +++ b/cli/generate-schema.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env npx tsx +/** + * Regenerate cli/wmill.schema.json from CONFIG_REFERENCE. + * + * Run after adding or modifying config options in src/commands/init/template.ts: + * npx tsx generate-schema.ts + */ +import { writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { generateJsonSchema } from "./src/commands/init/template.ts"; + +const dir = dirname(fileURLToPath(import.meta.url)); +const out = join(dir, "wmill.schema.json"); +writeFileSync(out, JSON.stringify(generateJsonSchema(), null, 2) + "\n"); +console.log(`Wrote ${out}`); diff --git a/cli/src/commands/config/config.ts b/cli/src/commands/config/config.ts new file mode 100644 index 0000000000..500e65a113 --- /dev/null +++ b/cli/src/commands/config/config.ts @@ -0,0 +1,26 @@ +import { Command } from "@cliffy/command"; +import * as log from "../../core/log.ts"; +import { + formatConfigReference, + formatConfigReferenceJson, +} from "../init/template.ts"; + +interface ConfigOptions { + json?: boolean; +} + +async function configAction(opts: ConfigOptions) { + if (opts.json) { + console.log(formatConfigReferenceJson()); + } else { + log.info(formatConfigReference()); + } +} + +const command = new Command() + .name("config") + .description("Show all available wmill.yaml configuration options") + .option("--json", "Output as JSON for programmatic consumption") + .action(configAction as any); + +export default command; diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index 5883967b77..dcd146b7e4 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -3,13 +3,14 @@ import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { Confirm } from "@cliffy/prompt/confirm"; import * as log from "../../core/log.ts"; -import { stringify as yamlStringify } from "yaml"; +import { type BranchBinding } from "./template.ts"; import { GlobalOptions } from "../../types.ts"; import { readLockfile } from "../../utils/metadata.ts"; import { getActiveWorkspaceOrFallback } from "../workspace/workspace.ts"; import { generateRTNamespace } from "../resource-type/resource-type.ts"; import { SKILLS, SKILL_CONTENT, SCHEMAS, SCHEMA_MAPPINGS } from "../../guidance/skills.ts"; import { generateAgentsMdContent } from "../../guidance/core.ts"; +import { generateCommentedTemplate } from "./template.ts"; /** * Format a YAML schema for inclusion in skill markdown files. @@ -44,59 +45,35 @@ async function initAction(opts: InitOptions) { if (await stat("wmill.yaml").catch(() => null)) { log.error(colors.red("wmill.yaml already exists")); } else { - // Import DEFAULT_SYNC_OPTIONS from conf.ts - const { DEFAULT_SYNC_OPTIONS } = await import("../../core/conf.ts"); - - // Create initial config with defaults - const initialConfig = { ...DEFAULT_SYNC_OPTIONS } as any; - - // Add branch structure + // Detect current git branch for template const { isGitRepository, getCurrentGitBranch } = await import( "../../utils/git.ts" ); + let branchName: string | undefined; + let binding: BranchBinding | undefined; if (isGitRepository()) { - const currentBranch = getCurrentGitBranch(); - if (currentBranch) { - initialConfig.gitBranches = { - [currentBranch]: { overrides: {} }, - }; - } else { - initialConfig.gitBranches = {}; - } - } else { - initialConfig.gitBranches = {}; + branchName = getCurrentGitBranch() ?? undefined; } - initialConfig.nonDottedPaths = true; - await writeFile("wmill.yaml", yamlStringify(initialConfig), "utf-8"); - log.info(colors.green("wmill.yaml created with default settings")); - - // Create lock file - await readLockfile(); - - // Offer to bind workspace profile to current branch - if (isGitRepository()) { + // Determine workspace binding before writing the template + if (isGitRepository() && branchName) { const activeWorkspace = await getActiveWorkspaceOrFallback( opts as GlobalOptions ); - const currentBranch = getCurrentGitBranch(); - if (activeWorkspace && currentBranch) { - // Determine binding behavior based on flags + if (activeWorkspace) { const shouldBind = opts.bindProfile === true; const shouldPrompt = opts.bindProfile === undefined && !!process.stdin.isTTY && !opts.useDefault; - const shouldSkip = opts.bindProfile != true && - (opts.useDefault || !!!process.stdin.isTTY); + (opts.useDefault || !process.stdin.isTTY); if (!shouldSkip) { - // Show workspace info if we're binding or prompting if (shouldBind || shouldPrompt) { log.info( - colors.yellow(`\nCurrent Git branch: ${colors.bold(currentBranch)}`) + colors.yellow(`\nCurrent Git branch: ${colors.bold(branchName)}`) ); log.info( colors.yellow( @@ -118,37 +95,31 @@ async function initAction(opts: InitOptions) { default: true, }))) ) { - // Update the config with workspace binding - const currentConfig = await import("../../core/conf.ts").then((m) => - m.readConfigFile() - ); - if (!currentConfig.gitBranches) { - currentConfig.gitBranches = {}; - } - if (!currentConfig.gitBranches[currentBranch]) { - currentConfig.gitBranches[currentBranch] = { overrides: {} }; - } - log.info( - `binding branch ${currentBranch} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}` - ); - currentConfig.gitBranches[currentBranch].baseUrl = - activeWorkspace.remote; - currentConfig.gitBranches[currentBranch].workspaceId = - activeWorkspace.workspaceId; - - await writeFile("wmill.yaml", yamlStringify(currentConfig), "utf-8"); - - log.info( - colors.green( - `✓ Bound branch '${currentBranch}' to workspace '${activeWorkspace.name}'` - ) + `binding branch ${branchName} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}` ); + binding = { + baseUrl: activeWorkspace.remote, + workspaceId: activeWorkspace.workspaceId, + }; } } } } + await writeFile("wmill.yaml", generateCommentedTemplate(branchName, binding), "utf-8"); + log.info(colors.green("wmill.yaml created with default settings")); + if (binding) { + log.info( + colors.green( + `✓ Bound branch '${branchName}' to workspace` + ) + ); + } + + // Create lock file + await readLockfile(); + // Check for backend git-sync settings unless --use-default is specified if (!opts.useDefault) { try { diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts new file mode 100644 index 0000000000..0684b7ca14 --- /dev/null +++ b/cli/src/commands/init/template.ts @@ -0,0 +1,395 @@ +/** + * Configuration option descriptor — each entry IS a JSON Schema property + * with extra metadata for template rendering and reference table display. + * + * To generate the JSON Schema: iterate entries, strip NON_SCHEMA_KEYS, done. + * Sub-fields of complex types (codebases items, gitBranches branch config) + * are defined inline in the parent's schema — no duplicate entries needed. + * The reference table auto-expands nested schemas into rows. + * + * Adding a new option: + * 1. Add an entry to CONFIG_REFERENCE with JSON Schema type fields + description + * 2. Add template rendering hints (section, commented, templateValue, etc.) + * 3. `wmill init` (YAML template), `wmill config` (table), and wmill.schema.json all update automatically + */ +export interface ConfigOption { + // --- JSON Schema fields (kept when generating schema) --- + type: string; + description: string; + enum?: string[]; + items?: Record; + properties?: Record; + additionalProperties?: Record | boolean; + required?: string[]; + + // --- Non-schema metadata (stripped when generating schema) --- + name: string; + default: string; + + // --- Template rendering hints (also stripped) --- + section?: string; + sectionNote?: string; + commented?: boolean; + templateValue?: string; + example?: string; + inlineComment?: string; + groupNote?: string; +} + +/** Keys to strip from ConfigOption entries when generating JSON Schema. */ +const NON_SCHEMA_KEYS = new Set([ + "name", "default", + "section", "sectionNote", "commented", "templateValue", + "example", "inlineComment", "groupNote", +]); + +// Reusable sub-schemas for nested types +const SPECIFIC_ITEMS_SCHEMA = { + type: "object", + description: "Sync only specific items", + properties: { + variables: { type: "array", items: { type: "string" }, description: "Specific variable paths to sync" }, + resources: { type: "array", items: { type: "string" }, description: "Specific resource paths to sync" }, + triggers: { type: "array", items: { type: "string" }, description: "Specific trigger paths to sync" }, + folders: { type: "array", items: { type: "string" }, description: "Specific folder paths to sync" }, + settings: { type: "boolean", description: "Whether to sync settings" }, + }, + additionalProperties: false, +} as const; + +const BRANCH_CONFIG_SCHEMA = { + type: "object", + properties: { + baseUrl: { type: "string", description: "Windmill instance URL for this branch" }, + workspaceId: { type: "string", description: "Workspace ID to sync with for this branch" }, + overrides: { type: "object", description: "Override any top-level sync option for this branch" }, + promotionOverrides: { type: "object", description: "Overrides applied when using --promotion flag" }, + specificItems: SPECIFIC_ITEMS_SCHEMA, + }, + additionalProperties: false, +} as const; + +/** + * All wmill.yaml configuration options — single source of truth. + * Each entry is a JSON Schema property with extra metadata. + */ +export const CONFIG_REFERENCE: ConfigOption[] = [ + // ── Core ────────────────────────────────────────────────────────────── + { name: "defaultTs", type: "string", enum: ["bun", "deno"], default: "bun", description: "Default TypeScript runtime for new scripts" }, + { name: "includes", type: "array", items: { type: "string" }, default: '["f/**"]', description: "Glob patterns for files to include in sync", + templateValue: '\n - "f/**"' }, + { name: "extraIncludes", type: "array", items: { type: "string" }, default: "[]", description: "Additional glob patterns merged with includes (useful in branch overrides)", + commented: true }, + { name: "excludes", type: "array", items: { type: "string" }, default: "[]", description: "Glob patterns for files to exclude from sync" }, + + // ── What to sync ────────────────────────────────────────────────────── + { name: "skipVariables", type: "boolean", default: "false", description: "Skip syncing variables", + section: "What to sync", sectionNote: '"skip" options default to false (synced), "include" options default to false (not synced)' }, + { name: "skipResources", type: "boolean", default: "false", description: "Skip syncing resources" }, + { name: "skipResourceTypes", type: "boolean", default: "false", description: "Skip syncing resource types" }, + { name: "skipSecrets", type: "boolean", default: "true", description: "Skip syncing secrets (true by default for security)", + inlineComment: "true by default — secrets are not synced for security" }, + { name: "skipScripts", type: "boolean", default: "false", description: "Skip syncing scripts" }, + { name: "skipFlows", type: "boolean", default: "false", description: "Skip syncing flows" }, + { name: "skipApps", type: "boolean", default: "false", description: "Skip syncing apps" }, + { name: "skipFolders", type: "boolean", default: "false", description: "Skip syncing folders" }, + { name: "skipWorkspaceDependencies", type: "boolean", default: "false", description: "Skip syncing workspace dependencies" }, + + { name: "includeSchedules", type: "boolean", default: "false", description: "Include schedules in sync", + commented: true, templateValue: "true", groupNote: "Uncomment to include these (excluded by default):" }, + { name: "includeTriggers", type: "boolean", default: "false", description: "Include triggers (http, websocket, kafka, etc.) in sync", + commented: true, templateValue: "true" }, + { name: "includeUsers", type: "boolean", default: "false", description: "Include workspace users in sync", + commented: true, templateValue: "true" }, + { name: "includeGroups", type: "boolean", default: "false", description: "Include workspace groups in sync", + commented: true, templateValue: "true" }, + { name: "includeSettings", type: "boolean", default: "false", description: "Include workspace settings in sync", + commented: true, templateValue: "true" }, + { name: "includeKey", type: "boolean", default: "false", description: "Include encryption key in sync", + commented: true, templateValue: "true" }, + + // ── Sync behavior ───────────────────────────────────────────────────── + { name: "parallel", type: "integer", default: "(unset)", description: "Number of parallel operations during sync", + section: "Sync behavior", commented: true, templateValue: "4" }, + { name: "locksRequired", type: "boolean", default: "false", description: "Require lock files for all scripts", + commented: true, templateValue: "true" }, + { name: "lint", type: "boolean", default: "false", description: "Run linting before push", + commented: true, templateValue: "true" }, + { name: "plainSecrets", type: "boolean", default: "false", description: "Handle secrets as plain text (not recommended)", + commented: true }, + { name: "message", type: "string", default: "(unset)", description: "Default commit message for sync operations", + commented: true, templateValue: '"my commit message"' }, + { name: "promotion", type: "string", default: "(unset)", description: "Branch name to use promotion overrides from during sync", + commented: true, templateValue: "staging" }, + { name: "skipBranchValidation", type: "boolean", default: "false", description: "Skip validation that current git branch matches a configured branch", + commented: true }, + { name: "nonDottedPaths", type: "boolean", default: "true", description: "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app" }, + + // ── Codebase bundling ───────────────────────────────────────────────── + { name: "codebases", type: "array", default: "[]", description: "Codebase bundling configurations for shared libraries", + items: { + type: "object", + properties: { + relative_path: { type: "string", description: "Path to the codebase directory" }, + includes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to include in bundle" }, + excludes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to exclude from bundle" }, + format: { type: "string", enum: ["cjs", "esm"], description: "Bundle output format" }, + external: { type: "array", items: { type: "string" }, description: "Dependencies to leave unbundled (externals)" }, + assets: { type: "array", items: { type: "object", properties: { from: { type: "string" }, to: { type: "string" } }, required: ["from", "to"] }, description: "Static files to copy into the bundle" }, + customBundler: { type: "string", description: "Path to a custom bundler script (replaces esbuild)" }, + inject: { type: "array", items: { type: "string" }, description: "Files to inject into every entry point" }, + define: { type: "object", additionalProperties: { type: "string" }, description: "Compile-time constant definitions" }, + banner: { type: "object", additionalProperties: { type: "string" }, description: "Text to prepend to output files by type" }, + loader: { type: "object", additionalProperties: { type: "string" }, description: "esbuild loader overrides by extension" }, + }, + required: ["relative_path"], + additionalProperties: false, + }, + section: "Codebase bundling (shared libraries)", + sectionNote: "Bundle TypeScript/JavaScript codebases that scripts import from.\nEach entry is bundled and uploaded so scripts can import shared code.", + example: [ + "# codebases:", + '# - relative_path: ./shared # path to the codebase', + '# includes: ["**/*.ts"] # files to include in bundle', + '# excludes: ["node_modules/**"] # files to exclude', + '# format: esm # bundle format: "cjs" or "esm"', + '# external: ["pg", "axios"] # dependencies to leave unbundled', + "# assets: # static files to copy into bundle", + "# - from: ./static", + "# to: ./dist", + "# # customBundler: ./build.ts # custom bundler script (replaces esbuild)", + '# # inject: ["./polyfills.ts"] # files to inject into every entry point', + "# # define: # compile-time constants", + "# # API_URL: '\"https://api.example.com\"'", + "# # banner: # text prepended to output files", + '# # js: "/* bundled by windmill */"', + "# # loader: # esbuild loader overrides", + '# # ".png": "dataurl"', + ].join("\n"), + }, + + // ── Git branches ────────────────────────────────────────────────────── + { name: "gitBranches", type: "object", default: "{}", description: "Map git branches to workspaces and per-branch sync overrides", + properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, + additionalProperties: BRANCH_CONFIG_SCHEMA, + section: "Git branch / environment bindings", + sectionNote: "Map git branches to Windmill workspaces and override settings per branch.\nUse \"environments\" as an alias if you prefer environment-based terminology.", + templateValue: "\n {{BRANCH}}:\n overrides: {}", + example: [ + "{{BASEURL_LINE}}", + "{{WORKSPACE_ID_LINE}}", + " # promotionOverrides: # overrides applied during --promotion", + " # skipSecrets: false", + " # specificItems: # only sync these specific items", + ' # variables: ["f/my_folder/my_var"]', + ' # resources: ["f/my_folder/my_res"]', + ' # triggers: ["f/my_folder/my_trigger"]', + ' # folders: ["my_folder"]', + " # settings: true", + "", + " # Example: staging branch bound to a different workspace", + " # staging:", + " # baseUrl: https://staging.windmill.dev", + " # workspaceId: staging-workspace", + " # overrides:", + " # skipSecrets: false", + " # includeSchedules: true", + "", + " # Items shared across ALL branches", + " # commonSpecificItems:", + ' # variables: ["f/shared/api_key"]', + ' # resources: ["f/shared/db_conn"]', + ' # folders: ["shared"]', + ].join("\n"), + }, + + { name: "environments", type: "object", default: "{}", description: "Alias for gitBranches — use if you prefer environment-based terminology", + properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, + additionalProperties: BRANCH_CONFIG_SCHEMA, + commented: true }, +]; + +// ─── Template generator ───────────────────────────────────────────────────── + +export interface BranchBinding { + baseUrl: string; + workspaceId: string; +} + +/** Quote a string for use as a YAML key if it contains special characters. */ +function yamlKey(s: string): string { + if ( + /^[a-zA-Z0-9_/.@-]+$/.test(s) && + !/^(true|false|yes|no|on|off|null|~)$/i.test(s) && + !/^\d+(\.\d+)?$/.test(s) + ) { + return s; + } + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function generateCommentedTemplate(branchName?: string, binding?: BranchBinding): string { + const branch = yamlKey(branchName ?? "main"); + const lines: string[] = [ + "# yaml-language-server: $schema=wmill.schema.json", + "# wmill.yaml — Windmill CLI configuration", + '# Full reference: run "wmill config"', + "", + ]; + + for (const opt of CONFIG_REFERENCE) { + if (opt.section) { + const ruler = "-".repeat(Math.max(0, 65 - opt.section.length)); + lines.push(`# --- ${opt.section} ${ruler}`); + if (opt.sectionNote) { + for (const noteLine of opt.sectionNote.split("\n")) { + lines.push(`# ${noteLine}`); + } + } + lines.push(""); + } + + if (opt.groupNote) { + lines.push(`# ${opt.groupNote}`); + } + + const value = opt.templateValue ?? opt.default; + const resolvedValue = value.replace("{{BRANCH}}", branch); + + if (opt.commented) { + lines.push(`# ${opt.description}`); + lines.push(`# ${opt.name}: ${resolvedValue}`); + } else { + lines.push(`# ${opt.description}`); + if (opt.inlineComment) { + const base = `${opt.name}: ${resolvedValue}`; + const pad = " ".repeat(Math.max(1, 32 - base.length)); + lines.push(`${base}${pad}# ${opt.inlineComment}`); + } else { + lines.push(`${opt.name}: ${resolvedValue}`); + } + } + + if (opt.example) { + let resolvedExample = opt.example.replace(/\{\{BRANCH\}\}/g, branch); + if (binding) { + resolvedExample = resolvedExample + .replace("{{BASEURL_LINE}}", ` baseUrl: ${binding.baseUrl}`) + .replace("{{WORKSPACE_ID_LINE}}", ` workspaceId: ${binding.workspaceId}`); + } else { + resolvedExample = resolvedExample + .replace("{{BASEURL_LINE}}", " # baseUrl: https://app.windmill.dev # Windmill instance URL for this branch") + .replace("{{WORKSPACE_ID_LINE}}", " # workspaceId: my-workspace # workspace to sync with"); + } + for (const exLine of resolvedExample.split("\n")) { + lines.push(exLine); + } + } + + lines.push(""); + } + + return lines.join("\n"); +} + +// ─── Reference formatters ─────────────────────────────────────────────────── + +/** Recursively expand a schema's properties into flat reference rows. */ +function expandSchema( + prefix: string, + schema: Record, + rows: { name: string; description: string; default: string }[] +): void { + if (schema.properties) { + for (const [key, prop] of Object.entries(schema.properties) as [string, Record][]) { + const name = prefix ? `${prefix}.${key}` : key; + rows.push({ name, description: prop.description ?? "", default: "" }); + // Recurse into nested object properties (e.g., specificItems) + if (prop.properties && prop.type === "object") { + expandSchema(name, prop, rows); + } + } + } +} + +export function formatConfigReference(): string { + const nameWidth = 48; + const descWidth = 70; + + const header = [ + "OPTION".padEnd(nameWidth), + "DESCRIPTION".padEnd(descWidth), + "DEFAULT", + ].join(" "); + + const separator = "-".repeat(header.length + 10); + + const allRows: { name: string; description: string; default: string }[] = []; + for (const opt of CONFIG_REFERENCE) { + allRows.push({ name: opt.name, description: opt.description, default: opt.default }); + + // Auto-expand array item properties (e.g., codebases[].*) + if (opt.items?.properties) { + expandSchema(`${opt.name}[]`, opt.items, allRows); + } + // Auto-expand additionalProperties (e.g., gitBranches..*) + if (opt.additionalProperties && typeof opt.additionalProperties === "object" && opt.additionalProperties.properties) { + expandSchema(`${opt.name}.`, opt.additionalProperties as Record, allRows); + } + // Auto-expand named properties (e.g., gitBranches.commonSpecificItems) + if (opt.properties) { + expandSchema(opt.name, opt, allRows); + } + } + + const rows = allRows.map((r) => + [r.name.padEnd(nameWidth), r.description.padEnd(descWidth), r.default].join(" ") + ); + + return [ + "wmill.yaml — Configuration Reference", + "", + "Full documentation: https://www.windmill.dev/docs/advanced/cli", + "", + separator, + header, + separator, + ...rows, + separator, + "", + 'Run "wmill init" to generate a wmill.yaml with commented examples.', + ].join("\n"); +} + +export function formatConfigReferenceJson(): string { + const clean = CONFIG_REFERENCE.map((opt) => ({ + name: opt.name, type: opt.type, default: opt.default, description: opt.description, + })); + return JSON.stringify(clean, null, 2); +} + +// ─── JSON Schema generator ────────────────────────────────────────────────── + +/** + * Generate a JSON Schema for wmill.yaml by stripping non-schema keys from CONFIG_REFERENCE. + */ +export function generateJsonSchema(): Record { + const properties: Record = {}; + for (const opt of CONFIG_REFERENCE) { + const entry: Record = {}; + for (const [k, v] of Object.entries(opt)) { + if (!NON_SCHEMA_KEYS.has(k) && k !== "name") { + entry[k] = v; + } + } + properties[opt.name] = entry; + } + return { + $schema: "http://json-schema.org/draft-07/schema#", + title: "wmill.yaml", + description: "Windmill CLI configuration file. Full reference: wmill config", + type: "object", + properties, + additionalProperties: false, + }; +} diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 81c87dd05a..7da9abd72e 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -4999,6 +4999,13 @@ app related commands - \`--dry-run\` - Perform a dry run without making changes - \`--default-ts \` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- \`--json\` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands diff --git a/cli/src/main.ts b/cli/src/main.ts index 23e921d0b8..5126bc6c92 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -41,6 +41,7 @@ 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 config from "./commands/config/config.ts"; import { fetchVersion } from "./core/context.ts"; export { @@ -62,6 +63,7 @@ export { instance, dev, docs, + config, hubPull, pull, push, @@ -132,6 +134,7 @@ const command = new Command() .command("jobs", jobs) .command("generate-metadata", generateMetadata) .command("docs", docs) + .command("config", config) .command("version --version", "Show version information") .action(async (opts: any) => { console.log("CLI version: " + VERSION); diff --git a/cli/test/init_template.test.ts b/cli/test/init_template.test.ts new file mode 100644 index 0000000000..efb6d09891 --- /dev/null +++ b/cli/test/init_template.test.ts @@ -0,0 +1,244 @@ +/** + * Unit tests for wmill.yaml template generation, config reference, and JSON Schema. + */ + +import { expect, test, describe } from "bun:test"; +import { parse } from "yaml"; +import Ajv from "ajv"; +import { + generateCommentedTemplate, + generateJsonSchema, + formatConfigReference, + formatConfigReferenceJson, + CONFIG_REFERENCE, +} from "../src/commands/init/template.ts"; + +// ============================================================================= +// generateCommentedTemplate +// ============================================================================= + +describe("generateCommentedTemplate", () => { + test("produces valid YAML that parses without errors", () => { + const yaml = generateCommentedTemplate("main"); + const config = parse(yaml); + expect(config).toBeDefined(); + expect(typeof config).toBe("object"); + }); + + test("uses provided branch name in gitBranches", () => { + const config = parse(generateCommentedTemplate("my-feature")); + expect(config.gitBranches["my-feature"]).toBeDefined(); + expect(config.gitBranches["my-feature"].overrides).toEqual({}); + }); + + test("defaults to 'main' when no branch name given", () => { + const config = parse(generateCommentedTemplate()); + expect(config.gitBranches["main"]).toBeDefined(); + }); + + test("quotes branch names with YAML-special characters", () => { + const specialBranches = ["fix: something", "feat/my branch", "release#1"]; + for (const branch of specialBranches) { + const yaml = generateCommentedTemplate(branch); + const config = parse(yaml); + expect(config.gitBranches[branch]).toBeDefined(); + } + }); + + test("contains yaml-language-server schema directive", () => { + const yaml = generateCommentedTemplate("main"); + expect(yaml.startsWith("# yaml-language-server: $schema=wmill.schema.json")).toBe(true); + }); + + test("includes all non-commented CONFIG_REFERENCE entries as active YAML keys", () => { + const config = parse(generateCommentedTemplate("main")); + for (const opt of CONFIG_REFERENCE) { + if (!opt.commented) { + expect(config).toHaveProperty(opt.name); + } + } + }); + + test("does not include commented entries as active YAML keys", () => { + const config = parse(generateCommentedTemplate("main")); + for (const opt of CONFIG_REFERENCE) { + if (opt.commented && opt.name !== "environments") { + expect(config[opt.name]).toBeUndefined(); + } + } + }); + + test("default values match expected defaults", () => { + const config = parse(generateCommentedTemplate("main")); + expect(config.defaultTs).toBe("bun"); + expect(config.skipSecrets).toBe(true); + expect(config.nonDottedPaths).toBe(true); + expect(config.codebases).toEqual([]); + expect(config.excludes).toEqual([]); + expect(config.includes).toEqual(["f/**"]); + }); +}); + +// ============================================================================= +// generateJsonSchema +// ============================================================================= + +describe("generateJsonSchema", () => { + const schema = generateJsonSchema(); + + test("is a valid JSON Schema draft-07", () => { + expect(schema.$schema).toBe("http://json-schema.org/draft-07/schema#"); + expect(schema.type).toBe("object"); + expect(schema.properties).toBeDefined(); + }); + + test("validates the generated YAML template", () => { + const config = parse(generateCommentedTemplate("main")); + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate(config)).toBe(true); + }); + + test("rejects unknown keys", () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate({ unknownOption: true })).toBe(false); + }); + + test("rejects invalid enum values", () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate({ defaultTs: "python" })).toBe(false); + }); + + test("rejects wrong types", () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate({ skipSecrets: "yes" })).toBe(false); + }); + + test("includes codebases array schema with item properties", () => { + expect(schema.properties.codebases.type).toBe("array"); + expect(schema.properties.codebases.items.properties.relative_path).toBeDefined(); + expect(schema.properties.codebases.items.required).toContain("relative_path"); + }); + + test("includes gitBranches with branch config schema", () => { + const branchSchema = schema.properties.gitBranches.additionalProperties; + expect(branchSchema.properties.baseUrl).toBeDefined(); + expect(branchSchema.properties.workspaceId).toBeDefined(); + expect(branchSchema.properties.specificItems).toBeDefined(); + expect(branchSchema.properties.specificItems.properties.variables).toBeDefined(); + }); + + test("includes environments as alias for gitBranches", () => { + expect(schema.properties.environments).toBeDefined(); + expect(schema.properties.environments.additionalProperties).toEqual( + schema.properties.gitBranches.additionalProperties + ); + }); + + test("does not contain template-only keys in schema output", () => { + const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote"]; + const json = JSON.stringify(schema); + for (const key of templateKeys) { + expect(json).not.toContain(`"${key}"`); + } + }); +}); + +// ============================================================================= +// formatConfigReference +// ============================================================================= + +describe("formatConfigReference", () => { + const output = formatConfigReference(); + + test("includes header row", () => { + expect(output).toContain("OPTION"); + expect(output).toContain("DESCRIPTION"); + expect(output).toContain("DEFAULT"); + }); + + test("includes all top-level CONFIG_REFERENCE entries", () => { + for (const opt of CONFIG_REFERENCE) { + expect(output).toContain(opt.name); + } + }); + + test("auto-expands codebases sub-fields", () => { + expect(output).toContain("codebases[].relative_path"); + expect(output).toContain("codebases[].format"); + expect(output).toContain("codebases[].external"); + }); + + test("auto-expands gitBranches sub-fields", () => { + expect(output).toContain("gitBranches..baseUrl"); + expect(output).toContain("gitBranches..workspaceId"); + expect(output).toContain("gitBranches..specificItems.variables"); + }); + + test("auto-expands commonSpecificItems sub-fields", () => { + expect(output).toContain("gitBranches.commonSpecificItems.variables"); + expect(output).toContain("gitBranches.commonSpecificItems.settings"); + }); +}); + +// ============================================================================= +// formatConfigReferenceJson +// ============================================================================= + +describe("formatConfigReferenceJson", () => { + test("produces valid JSON", () => { + const parsed = JSON.parse(formatConfigReferenceJson()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBe(CONFIG_REFERENCE.length); + }); + + test("each entry has name, type, default, description", () => { + const parsed = JSON.parse(formatConfigReferenceJson()); + for (const entry of parsed) { + expect(entry).toHaveProperty("name"); + expect(entry).toHaveProperty("type"); + expect(entry).toHaveProperty("default"); + expect(entry).toHaveProperty("description"); + } + }); + + test("does not contain template-only keys", () => { + const parsed = JSON.parse(formatConfigReferenceJson()); + const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote"]; + for (const entry of parsed) { + for (const key of templateKeys) { + expect(entry).not.toHaveProperty(key); + } + } + }); +}); + +// ============================================================================= +// CONFIG_REFERENCE integrity +// ============================================================================= + +describe("CONFIG_REFERENCE integrity", () => { + test("all entries have required fields", () => { + for (const opt of CONFIG_REFERENCE) { + expect(opt.name).toBeTruthy(); + expect(opt.type).toBeTruthy(); + expect(opt.description).toBeTruthy(); + expect(opt.default).toBeDefined(); + } + }); + + test("no duplicate names", () => { + const names = CONFIG_REFERENCE.map((o) => o.name); + expect(new Set(names).size).toBe(names.length); + }); + + test("type field uses valid JSON Schema types", () => { + const validTypes = new Set(["boolean", "string", "integer", "number", "array", "object"]); + for (const opt of CONFIG_REFERENCE) { + expect(validTypes.has(opt.type)).toBe(true); + } + }); +}); diff --git a/cli/wmill.schema.json b/cli/wmill.schema.json new file mode 100644 index 0000000000..7129563f3f --- /dev/null +++ b/cli/wmill.schema.json @@ -0,0 +1,439 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "wmill.yaml", + "description": "Windmill CLI configuration file. Full reference: wmill config", + "type": "object", + "properties": { + "defaultTs": { + "type": "string", + "enum": [ + "bun", + "deno" + ], + "description": "Default TypeScript runtime for new scripts" + }, + "includes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to include in sync" + }, + "extraIncludes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional glob patterns merged with includes (useful in branch overrides)" + }, + "excludes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to exclude from sync" + }, + "skipVariables": { + "type": "boolean", + "description": "Skip syncing variables" + }, + "skipResources": { + "type": "boolean", + "description": "Skip syncing resources" + }, + "skipResourceTypes": { + "type": "boolean", + "description": "Skip syncing resource types" + }, + "skipSecrets": { + "type": "boolean", + "description": "Skip syncing secrets (true by default for security)" + }, + "skipScripts": { + "type": "boolean", + "description": "Skip syncing scripts" + }, + "skipFlows": { + "type": "boolean", + "description": "Skip syncing flows" + }, + "skipApps": { + "type": "boolean", + "description": "Skip syncing apps" + }, + "skipFolders": { + "type": "boolean", + "description": "Skip syncing folders" + }, + "skipWorkspaceDependencies": { + "type": "boolean", + "description": "Skip syncing workspace dependencies" + }, + "includeSchedules": { + "type": "boolean", + "description": "Include schedules in sync" + }, + "includeTriggers": { + "type": "boolean", + "description": "Include triggers (http, websocket, kafka, etc.) in sync" + }, + "includeUsers": { + "type": "boolean", + "description": "Include workspace users in sync" + }, + "includeGroups": { + "type": "boolean", + "description": "Include workspace groups in sync" + }, + "includeSettings": { + "type": "boolean", + "description": "Include workspace settings in sync" + }, + "includeKey": { + "type": "boolean", + "description": "Include encryption key in sync" + }, + "parallel": { + "type": "integer", + "description": "Number of parallel operations during sync" + }, + "locksRequired": { + "type": "boolean", + "description": "Require lock files for all scripts" + }, + "lint": { + "type": "boolean", + "description": "Run linting before push" + }, + "plainSecrets": { + "type": "boolean", + "description": "Handle secrets as plain text (not recommended)" + }, + "message": { + "type": "string", + "description": "Default commit message for sync operations" + }, + "promotion": { + "type": "string", + "description": "Branch name to use promotion overrides from during sync" + }, + "skipBranchValidation": { + "type": "boolean", + "description": "Skip validation that current git branch matches a configured branch" + }, + "nonDottedPaths": { + "type": "boolean", + "description": "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app" + }, + "codebases": { + "type": "array", + "description": "Codebase bundling configurations for shared libraries", + "items": { + "type": "object", + "properties": { + "relative_path": { + "type": "string", + "description": "Path to the codebase directory" + }, + "includes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to include in bundle" + }, + "excludes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to exclude from bundle" + }, + "format": { + "type": "string", + "enum": [ + "cjs", + "esm" + ], + "description": "Bundle output format" + }, + "external": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Dependencies to leave unbundled (externals)" + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "from", + "to" + ] + }, + "description": "Static files to copy into the bundle" + }, + "customBundler": { + "type": "string", + "description": "Path to a custom bundler script (replaces esbuild)" + }, + "inject": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Files to inject into every entry point" + }, + "define": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Compile-time constant definitions" + }, + "banner": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Text to prepend to output files by type" + }, + "loader": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "esbuild loader overrides by extension" + } + }, + "required": [ + "relative_path" + ], + "additionalProperties": false + } + }, + "gitBranches": { + "type": "object", + "description": "Map git branches to workspaces and per-branch sync overrides", + "properties": { + "commonSpecificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this branch" + }, + "workspaceId": { + "type": "string", + "description": "Workspace ID to sync with for this branch" + }, + "overrides": { + "type": "object", + "description": "Override any top-level sync option for this branch" + }, + "promotionOverrides": { + "type": "object", + "description": "Overrides applied when using --promotion flag" + }, + "specificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "environments": { + "type": "object", + "description": "Alias for gitBranches — use if you prefer environment-based terminology", + "properties": { + "commonSpecificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this branch" + }, + "workspaceId": { + "type": "string", + "description": "Workspace ID to sync with for this branch" + }, + "overrides": { + "type": "object", + "description": "Override any top-level sync option for this branch" + }, + "promotionOverrides": { + "type": "object", + "description": "Overrides applied when using --promotion flag" + }, + "specificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index d76e31ded0..c582a277b4 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -41,6 +41,13 @@ app related commands - `--dry-run` - Perform a dry run without making changes - `--default-ts ` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- `--json` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index dc47b66eca..106c629021 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1568,6 +1568,13 @@ app related commands - \`--dry-run\` - Perform a dry run without making changes - \`--default-ts \` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- \`--json\` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 8a9f231fc2..30c31c4bcb 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -46,6 +46,13 @@ app related commands - `--dry-run` - Perform a dry run without making changes - `--default-ts ` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- `--json` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands From e15bfbf91ee1517432a6861ebb48e129485006aa Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 11:38:20 +0000 Subject: [PATCH 067/153] fix: sanitize flow step summaries for filesystem-safe names (#8554) * fix: sanitize flow step summaries for filesystem-safe names Co-Authored-By: Claude Opus 4.5 * chore: bump windmill-utils-internal to 1.3.6 Co-Authored-By: Claude Opus 4.5 * fix: handle Windows reserved device names in flow step sanitization Co-Authored-By: Claude Opus 4.5 * fix: collapse consecutive underscores in sanitized flow step names Co-Authored-By: Claude Opus 4.5 * chore: bump windmill-utils-internal to 1.3.7 Co-Authored-By: Claude Opus 4.5 * bump --------- Co-authored-by: Claude Opus 4.5 --- cli/windmill-utils-internal/package-lock.json | 4 +-- cli/windmill-utils-internal/package.json | 2 +- .../src/config/index.ts | 2 +- cli/windmill-utils-internal/src/index.ts | 10 +++---- .../src/inline-scripts/extractor.ts | 4 +-- .../src/inline-scripts/index.ts | 4 +-- .../src/inline-scripts/replacer.ts | 2 +- .../src/parse/index.ts | 2 +- .../src/path-utils/index.ts | 2 +- .../src/path-utils/path-assigner.ts | 28 +++++++++++++++++-- 10 files changed, 41 insertions(+), 19 deletions(-) diff --git a/cli/windmill-utils-internal/package-lock.json b/cli/windmill-utils-internal/package-lock.json index 57d295218f..e9ce5e3c9a 100644 --- a/cli/windmill-utils-internal/package-lock.json +++ b/cli/windmill-utils-internal/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-utils-internal", - "version": "1.3.5", + "version": "1.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-utils-internal", - "version": "1.3.5", + "version": "1.3.6", "license": "Apache 2.0", "devDependencies": { "@types/node": "^24.2.0", diff --git a/cli/windmill-utils-internal/package.json b/cli/windmill-utils-internal/package.json index d5c428b35f..07cf8b90b0 100644 --- a/cli/windmill-utils-internal/package.json +++ b/cli/windmill-utils-internal/package.json @@ -1,6 +1,6 @@ { "name": "windmill-utils-internal", - "version": "1.3.5", + "version": "1.3.7", "description": "Internal utility functions for Windmill", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/cli/windmill-utils-internal/src/config/index.ts b/cli/windmill-utils-internal/src/config/index.ts index f3ae42b3c8..e23ba6ca86 100644 --- a/cli/windmill-utils-internal/src/config/index.ts +++ b/cli/windmill-utils-internal/src/config/index.ts @@ -1 +1 @@ -export * from "./config.ts"; \ No newline at end of file +export * from "./config"; \ No newline at end of file diff --git a/cli/windmill-utils-internal/src/index.ts b/cli/windmill-utils-internal/src/index.ts index 635893e2d1..da314a7c5a 100644 --- a/cli/windmill-utils-internal/src/index.ts +++ b/cli/windmill-utils-internal/src/index.ts @@ -8,8 +8,8 @@ * - Cross-platform path constants */ -export * from "./inline-scripts.ts"; -export * from "./path-utils.ts"; -export * from "./parse.ts"; -export * from "./config.ts"; -export { SEP, DELIMITER } from "./constants.ts"; \ No newline at end of file +export * from "./inline-scripts"; +export * from "./path-utils"; +export * from "./parse"; +export * from "./config"; +export { SEP, DELIMITER } from "./constants"; \ No newline at end of file diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index a3572ce7eb..0ad1bb8302 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -1,5 +1,5 @@ -import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner.ts"; -import { FlowModule, RawScript, ScriptLang } from "../gen/types.gen.ts"; +import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner"; +import { FlowModule, RawScript, ScriptLang } from "../gen/types.gen"; /** * Represents an inline script extracted from a flow module diff --git a/cli/windmill-utils-internal/src/inline-scripts/index.ts b/cli/windmill-utils-internal/src/inline-scripts/index.ts index bb3c917dbb..eace8d3e4f 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/index.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/index.ts @@ -1,2 +1,2 @@ -export * from "./replacer.ts"; -export * from "./extractor.ts"; \ No newline at end of file +export * from "./replacer"; +export * from "./extractor"; \ No newline at end of file diff --git a/cli/windmill-utils-internal/src/inline-scripts/replacer.ts b/cli/windmill-utils-internal/src/inline-scripts/replacer.ts index 11b2cfaa1b..c5651752f1 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/replacer.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/replacer.ts @@ -1,4 +1,4 @@ -import { AiAgent, FlowModule, FlowValue, RawScript } from "../gen/types.gen.ts"; +import { AiAgent, FlowModule, FlowValue, RawScript } from "../gen/types.gen"; export type LocalScriptInfo = { content: string; diff --git a/cli/windmill-utils-internal/src/parse/index.ts b/cli/windmill-utils-internal/src/parse/index.ts index 41d09ed00d..fc26ce611a 100644 --- a/cli/windmill-utils-internal/src/parse/index.ts +++ b/cli/windmill-utils-internal/src/parse/index.ts @@ -1 +1 @@ -export * from "./parse-schema.ts"; \ No newline at end of file +export * from "./parse-schema"; \ No newline at end of file diff --git a/cli/windmill-utils-internal/src/path-utils/index.ts b/cli/windmill-utils-internal/src/path-utils/index.ts index 6f5c8d68be..ef23185664 100644 --- a/cli/windmill-utils-internal/src/path-utils/index.ts +++ b/cli/windmill-utils-internal/src/path-utils/index.ts @@ -1 +1 @@ -export * from "./path-assigner.ts"; \ No newline at end of file +export * from "./path-assigner"; \ No newline at end of file diff --git a/cli/windmill-utils-internal/src/path-utils/path-assigner.ts b/cli/windmill-utils-internal/src/path-utils/path-assigner.ts index 3fedbd8d37..a2fc5b8cab 100644 --- a/cli/windmill-utils-internal/src/path-utils/path-assigner.ts +++ b/cli/windmill-utils-internal/src/path-utils/path-assigner.ts @@ -1,4 +1,4 @@ -import { RawScript } from "../gen/types.gen.ts"; +import { RawScript } from "../gen/types.gen"; const INLINE_SCRIPT_PREFIX = "inline_script"; @@ -111,6 +111,28 @@ export function getLanguageFromExtension( return undefined; } +/** + * Sanitizes a summary string for use as a filesystem-safe name. + * Removes or replaces characters that are invalid on common filesystems. + */ +const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/; + +export function sanitizeForFilesystem(summary: string): string { + const name = summary + .toLowerCase() + .replaceAll(" ", "_") + // Remove characters invalid on Windows/Unix/Mac: / \ : * ? " < > | + // Also remove control characters (0x00-0x1F) and DEL (0x7F) + // deno-lint-ignore no-control-regex + .replace(/[/\\:*?"<>|\x00-\x1f\x7f]/g, "") + // Collapse consecutive underscores + .replace(/_+/g, "_") + // Trim leading/trailing dots and underscores (hidden files, Windows edge cases) + .replace(/^[._]+|[._]+$/g, ""); + // Prefix Windows reserved device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9) + return WINDOWS_RESERVED.test(name) ? `_${name}` : name; +} + export interface PathAssigner { assignPath(summary: string | undefined, language: SupportedLanguage): [string, string]; } @@ -144,7 +166,7 @@ export function newPathAssigner(defaultTs: "bun" | "deno" | PathAssignerOptions, ): [string, string] { let name; - name = summary?.toLowerCase()?.replaceAll(" ", "_") ?? ""; + name = summary ? sanitizeForFilesystem(summary) : ""; let original_name = name; @@ -185,7 +207,7 @@ export function newRawAppPathAssigner(defaultTs: "bun" | "deno"): PathAssigner { ): [string, string] { let name; - name = summary?.toLowerCase()?.replaceAll(" ", "_") ?? ""; + name = summary ? sanitizeForFilesystem(summary) : ""; let original_name = name; From 943fe9c6cc9b046e24007e45b5c37afc4804256a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 11:54:45 +0000 Subject: [PATCH 068/153] fix: handle inline script deletion in sync push + flow new nonDottedPaths (#8553) * fix: handle inline script file deletions in app/flow folders during sync push Co-Authored-By: Claude Opus 4.6 (1M context) * test: add regression test for app inline script deletion during sync push Co-Authored-By: Claude Opus 4.6 (1M context) * fix: flow new respects nonDottedPaths setting Co-Authored-By: Claude Opus 4.6 (1M context) * test: add flow new nonDottedPaths test Co-Authored-By: Claude Opus 4.6 (1M context) * fix: separate stat from pushObj in delete handler to avoid masking errors Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/commands/flow/flow.ts | 10 +- cli/src/commands/sync/sync.ts | 90 ++++++++++-- cli/src/types.ts | 15 +- cli/src/utils/resource_folders.ts | 22 +++ cli/test/app_inline_script_delete.test.ts | 158 ++++++++++++++++++++++ cli/test/list_get_new_commands.test.ts | 32 +++++ cli/test/resource_folders_unit.test.ts | 34 +++++ 7 files changed, 347 insertions(+), 14 deletions(-) create mode 100644 cli/test/app_inline_script_delete.test.ts diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 58d0777f90..7e8bd8c28f 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -10,6 +10,7 @@ import { yamlParseFile } from "../../utils/yaml.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { readFile } from "node:fs/promises"; import { mkdirSync, writeFileSync } from "node:fs"; +import { buildFolderPath, getMetadataFileName, loadNonDottedPathsSetting } from "../../utils/resource_folders.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; @@ -516,7 +517,7 @@ export async function generateLocks( } } -export function bootstrap( +export async function bootstrap( opts: GlobalOptions & { summary: string; description: string }, flowPath: string ) { @@ -524,7 +525,9 @@ export function bootstrap( return; } - const flowDirFullPath = `${flowPath}.flow`; + await loadNonDottedPathsSetting(); + + const flowDirFullPath = buildFolderPath(flowPath, "flow"); mkdirSync(flowDirFullPath, { recursive: false }); const newFlowDefinition = defaultFlowDefinition(); @@ -539,7 +542,8 @@ export function bootstrap( newFlowDefinition as Record ); - const flowYamlPath = `${flowDirFullPath}/flow.yaml`; + const metadataFile = getMetadataFileName("flow", "yaml"); + const flowYamlPath = `${flowDirFullPath}/${metadataFile}`; writeFileSync(flowYamlPath, newFlowDefinitionYaml, { flag: "wx", encoding: "utf-8" }); } diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index d62c39cba9..f2d15916d6 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -93,6 +93,8 @@ import { isAppMetadataFile, isRawAppMetadataFile, isRawAppFolderMetadataFile, + isAppFolderMetadataFile, + isFlowFolderMetadataFile, getDeleteSuffix, transformJsonPathToDir, getFolderSuffix, @@ -3160,16 +3162,88 @@ export async function push( }); break; case "flow": - await wmill.deleteFlowByPath({ - workspace: workspaceId, - path: removeSuffix(target, getDeleteSuffix("flow", "json")), - }); + if (isFlowFolderMetadataFile(target)) { + // Metadata file deleted — delete the entire flow + await wmill.deleteFlowByPath({ + workspace: workspaceId, + path: removeSuffix(target, getDeleteSuffix("flow", "json")), + }); + } else { + // Inline script file deleted within flow folder + const flowFolder = extractFolderPath(target, "flow"); + let flowFolderExists = false; + if (flowFolder) { + try { + await stat(flowFolder); + flowFolderExists = true; + } catch { + // folder doesn't exist + } + } + if (flowFolderExists) { + // Re-push the entire flow so the backend gets the updated definition + await pushObj( + workspaceId, + target, + undefined, + undefined, + opts.plainSecrets ?? false, + alreadySynced, + opts.message, + ); + } else { + // Flow folder doesn't exist locally — delete on server + const remotePath = extractResourceName(target, "flow"); + if (remotePath) { + await wmill.deleteFlowByPath({ + workspace: workspaceId, + path: remotePath, + }); + } + } + } break; case "app": - await wmill.deleteApp({ - workspace: workspaceId, - path: removeSuffix(target, getDeleteSuffix("app", "json")), - }); + if (isAppFolderMetadataFile(target)) { + // Metadata file deleted — delete the entire app + await wmill.deleteApp({ + workspace: workspaceId, + path: removeSuffix(target, getDeleteSuffix("app", "json")), + }); + } else { + // Inline script file deleted within app folder + const appFolder = extractFolderPath(target, "app"); + let appFolderExists = false; + if (appFolder) { + try { + await stat(appFolder); + appFolderExists = true; + } catch { + // folder doesn't exist + } + } + if (appFolderExists) { + // Re-push the entire app so the backend gets the updated definition + await pushObj( + workspaceId, + target, + undefined, + undefined, + opts.plainSecrets ?? false, + alreadySynced, + opts.message, + ); + } else { + // App folder doesn't exist locally — delete on server + const remotePath = extractResourceName(target, "app"); + if (remotePath) { + await wmill.deleteApp({ + workspace: workspaceId, + path: remotePath, + }); + } + } + } break; case "raw_app": if (isRawAppFolderMetadataFile(target)) { diff --git a/cli/src/types.ts b/cli/src/types.ts index 8ba36a0ea0..56fbf384fa 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -157,17 +157,26 @@ export async function pushObj( const typeEnding = getTypeStrFromPath(p); if (typeEnding === "app") { - const appName = extractResourceName(p, "app")!; + const appName = extractResourceName(p, "app"); + if (!appName) { + throw new Error(`Could not extract app name from path: ${p}`); + } await pushApp(workspace, appName, buildFolderPath(appName, "app"), message); } else if (typeEnding === "raw_app") { - const rawAppName = extractResourceName(p, "raw_app")!; + const rawAppName = extractResourceName(p, "raw_app"); + if (!rawAppName) { + throw new Error(`Could not extract raw app name from path: ${p}`); + } await pushRawApp(workspace, rawAppName, buildFolderPath(rawAppName, "raw_app"), message); } else if (typeEnding === "folder") { await pushFolder(workspace, p, befObj, newObj); } else if (typeEnding === "variable") { await pushVariable(workspace, p, befObj, newObj, plainSecrets); } else if (typeEnding === "flow") { - const flowName = extractResourceName(p, "flow")!; + const flowName = extractResourceName(p, "flow"); + if (!flowName) { + throw new Error(`Could not extract flow name from path: ${p}`); + } await pushFlow(workspace, flowName, buildFolderPath(flowName, "flow"), message); } else if (typeEnding === "resource") { if (!alreadySynced.includes(p)) { diff --git a/cli/src/utils/resource_folders.ts b/cli/src/utils/resource_folders.ts index 1531314f04..b4f204b1c6 100644 --- a/cli/src/utils/resource_folders.ts +++ b/cli/src/utils/resource_folders.ts @@ -453,6 +453,28 @@ export function isRawAppFolderMetadataFile(p: string): boolean { ); } +/** + * Check if a path ends with a specific app metadata file + * (inside the folder, e.g., ".app/app.yaml" or "__app/app.yaml") + */ +export function isAppFolderMetadataFile(p: string): boolean { + return ( + p.endsWith(getMetadataPathSuffix("app", "yaml")) || + p.endsWith(getMetadataPathSuffix("app", "json")) + ); +} + +/** + * Check if a path ends with a specific flow metadata file + * (inside the folder, e.g., ".flow/flow.yaml" or "__flow/flow.yaml") + */ +export function isFlowFolderMetadataFile(p: string): boolean { + return ( + p.endsWith(getMetadataPathSuffix("flow", "yaml")) || + p.endsWith(getMetadataPathSuffix("flow", "json")) + ); +} + // ============================================================================ // Script Module Path Functions // ============================================================================ diff --git a/cli/test/app_inline_script_delete.test.ts b/cli/test/app_inline_script_delete.test.ts new file mode 100644 index 0000000000..a33b912971 --- /dev/null +++ b/cli/test/app_inline_script_delete.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import * as path from "node:path"; +import { writeFile, readdir, stat, rm } from "node:fs/promises"; +import { getFolderSuffix, getMetadataFileName } from "../src/utils/resource_folders.ts"; + +// ============================================================================= +// APP INLINE SCRIPT DELETION TESTS +// Regression tests for: deleting inline script files within .app/ folders +// during sync push should re-push the app, not crash with TypeError. +// ============================================================================= + +async function fileExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +test("App: delete inline script file and push does not crash", async () => { + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "app_inline_delete_test", + token: backend.token, + }; + await addWorkspace(testWorkspace, { + force: true, + configDir: backend.testConfigDir, + }); + + await writeFile( + `${tempDir}/wmill.yaml`, + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []`, + "utf-8" + ); + + // Create an app with an inline script via the API + const appPath = "f/test/inline_delete_app"; + const inlineContent = `export async function main() {\n return "hello";\n}`; + + // Create the folder first + await backend.apiRequest!( + `/api/w/${backend.workspace}/folders/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "test" }), + } + ).then((r) => r.text()); + + await backend.createAppWithInlineScript!(appPath, inlineContent, "bun"); + + // ========================================================================= + // STEP 1: Pull — get the app folder with inline script files + // ========================================================================= + const pullResult1 = await backend.runCLICommand( + ["sync", "pull", "--yes"], + tempDir, + "app_inline_delete_test" + ); + expect(pullResult1.code).toEqual(0); + + // Find the app folder and its inline script files + const appSuffix = getFolderSuffix("app"); + const appDir = path.join(tempDir, appPath + appSuffix); + expect(await fileExists(appDir)).toBeTruthy(); + + // List files in the app folder to find inline script files + const appFiles = await readdir(appDir); + const inlineScriptFiles = appFiles.filter( + (f) => f.endsWith(".ts") || f.endsWith(".js") + ); + expect(inlineScriptFiles.length).toBeGreaterThan(0); + + const inlineScriptPath = path.join(appDir, inlineScriptFiles[0]); + expect(await fileExists(inlineScriptPath)).toBeTruthy(); + + const metadataFile = getMetadataFileName("app", "yaml"); + const appYamlPath = path.join(appDir, metadataFile); + + // ========================================================================= + // STEP 2: Remove the inline script from app.yaml and delete the .ts file + // ========================================================================= + // Replace the inline script with a static text component (no inline scripts) + const updatedAppYaml = `summary: Test app with inline script +value: + type: app + grid: + - id: text1 + data: + type: textcomponent + componentInput: + type: static + value: hello world + hiddenInlineScripts: [] + css: {} + norefreshbar: false +policy: + on_behalf_of: null + on_behalf_of_email: null + triggerables: {} + execution_mode: viewer +`; + await writeFile(appYamlPath, updatedAppYaml, "utf-8"); + + // Delete the inline script file + await rm(inlineScriptPath); + expect(await fileExists(inlineScriptPath)).toBeFalsy(); + + // Also delete any lock files for the inline script + for (const f of appFiles) { + if (f.endsWith(".lock")) { + await rm(path.join(appDir, f)); + } + } + + // ========================================================================= + // STEP 3: Push — should succeed, NOT crash with TypeError + // ========================================================================= + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes"], + tempDir, + "app_inline_delete_test" + ); + + // The critical assertion: push should not crash + expect(pushResult.code).toEqual(0); + + // ========================================================================= + // STEP 4: Verify by pulling again — inline script should be gone + // ========================================================================= + await rm(appDir, { recursive: true }); + + const pullResult2 = await backend.runCLICommand( + ["sync", "pull", "--yes"], + tempDir, + "app_inline_delete_test" + ); + expect(pullResult2.code).toEqual(0); + + // App should still exist + expect(await fileExists(appDir)).toBeTruthy(); + + // But no inline script files should be present + const finalFiles = await readdir(appDir); + const finalInlineScripts = finalFiles.filter( + (f) => + (f.endsWith(".ts") || f.endsWith(".js")) && + f.includes("inline_script") + ); + expect(finalInlineScripts.length).toEqual(0); + }); +}); diff --git a/cli/test/list_get_new_commands.test.ts b/cli/test/list_get_new_commands.test.ts index 8df67cab9b..df3d109630 100644 --- a/cli/test/list_get_new_commands.test.ts +++ b/cli/test/list_get_new_commands.test.ts @@ -438,6 +438,38 @@ describe("new command", () => { }); }); + test("flow new respects nonDottedPaths: true", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nnonDottedPaths: true\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["flow", "new", "f/test/nondot_flow", "--summary", "Non-dotted flow"], + tempDir + ); + + expect(result.code).toEqual(0); + + // Should use __flow suffix, not .flow + const flowYamlStat = await stat( + join(tempDir, "f/test/nondot_flow__flow/flow.yaml") + ); + expect(flowYamlStat.isFile()).toBe(true); + + const flowContent = await readFile( + join(tempDir, "f/test/nondot_flow__flow/flow.yaml"), + "utf-8" + ); + expect(flowContent).toContain("Non-dotted flow"); + }); + }); + test("flow bootstrap still works as alias", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); diff --git a/cli/test/resource_folders_unit.test.ts b/cli/test/resource_folders_unit.test.ts index 80732ebc1c..0d07812124 100644 --- a/cli/test/resource_folders_unit.test.ts +++ b/cli/test/resource_folders_unit.test.ts @@ -31,6 +31,8 @@ import { isAppMetadataFile, isRawAppMetadataFile, isRawAppFolderMetadataFile, + isAppFolderMetadataFile, + isFlowFolderMetadataFile, getDeleteSuffix, transformJsonPathToDir, isModuleEntryPoint, @@ -495,6 +497,38 @@ describe("isRawAppFolderMetadataFile", () => { }); }); +describe("isAppFolderMetadataFile", () => { + test("detects app folder metadata file (dotted)", () => { + expect(isAppFolderMetadataFile("f/common/landing.app/app.yaml")).toBe(true); + expect(isAppFolderMetadataFile("f/common/landing.app/app.json")).toBe(true); + }); + + test("rejects inline script files inside app folder", () => { + expect(isAppFolderMetadataFile("f/common/landing.app/eval_of_e.inline_script.frontend.js")).toBe(false); + expect(isAppFolderMetadataFile("f/common/landing.app/button1.inline_script.bun.ts")).toBe(false); + }); + + test("rejects top-level app metadata files", () => { + expect(isAppFolderMetadataFile("f/common/landing.app.json")).toBe(false); + expect(isAppFolderMetadataFile("f/common/landing.app.yaml")).toBe(false); + }); +}); + +describe("isFlowFolderMetadataFile", () => { + test("detects flow folder metadata file (dotted)", () => { + expect(isFlowFolderMetadataFile("f/common/my_flow.flow/flow.yaml")).toBe(true); + expect(isFlowFolderMetadataFile("f/common/my_flow.flow/flow.json")).toBe(true); + }); + + test("rejects inline script files inside flow folder", () => { + expect(isFlowFolderMetadataFile("f/common/my_flow.flow/step_0.inline_script.ts")).toBe(false); + }); + + test("rejects top-level flow metadata files", () => { + expect(isFlowFolderMetadataFile("f/common/my_flow.flow.json")).toBe(false); + }); +}); + // ============================================================================= // Sync-related Path Functions // ============================================================================= From 79cc4a92d88486c999799826bd0c9663767103f5 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 11:55:09 +0000 Subject: [PATCH 069/153] fix: emit 0 for OTEL queue metrics when tag queue is empty (#8559) Previously, windmill.queue.count and windmill.queue.running_count OTEL metrics would report no data instead of 0 when a tag's queue emptied. This was because the SQL query uses GROUP BY tag, so empty tags are absent from results. The Prometheus path already handled this by tracking previously-seen tags and emitting 0, but the OTEL path was missing this logic. Co-authored-by: Claude Opus 4.6 (1M context) --- backend/src/monitor.rs | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index bb7640c5ed..2b1196c8c2 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -169,6 +169,8 @@ lazy_static::lazy_static! { static ref QUEUE_COUNT_TAGS: Arc>> = Arc::new(RwLock::new(Vec::new())); static ref QUEUE_RUNNING_COUNT_TAGS: Arc>> = Arc::new(RwLock::new(Vec::new())); + static ref OTEL_QUEUE_COUNT_TAGS: Arc>> = Arc::new(RwLock::new(Vec::new())); + static ref OTEL_QUEUE_RUNNING_COUNT_TAGS: Arc>> = Arc::new(RwLock::new(Vec::new())); static ref DISABLE_CONCURRENCY_LIMIT: bool = std::env::var("DISABLE_CONCURRENCY_LIMIT").is_ok_and(|s| s == "true"); //legacy typo @@ -2372,8 +2374,20 @@ pub async fn expose_queue_metrics(db: &Pool) { } } + let otel_enabled = OTEL_METRICS_ENABLED.load(Ordering::Relaxed); + + if otel_enabled { + for q in OTEL_QUEUE_COUNT_TAGS.read().await.iter() { + if queue_counts.get(q).is_none() { + otel_set_queue_count(q, 0); + } + } + } + #[allow(unused_mut)] let mut tags_to_watch = vec![]; + #[allow(unused_mut)] + let mut otel_tags_to_watch = vec![]; for q in queue_counts { let count = q.1; let tag = q.0; @@ -2385,6 +2399,9 @@ pub async fn expose_queue_metrics(db: &Pool) { tags_to_watch.push(tag.to_string()); } + if otel_enabled { + otel_tags_to_watch.push(tag.to_string()); + } otel_set_queue_count(&tag, count as i64); // save queue_count and delay metrics per tag @@ -2419,9 +2436,13 @@ pub async fn expose_queue_metrics(db: &Pool) { let mut w = QUEUE_COUNT_TAGS.write().await; *w = tags_to_watch; } + if otel_enabled { + let mut w = OTEL_QUEUE_COUNT_TAGS.write().await; + *w = otel_tags_to_watch; + } // Single DB query for running counts, shared by Prometheus and OTel - let otel_running = OTEL_METRICS_ENABLED.load(Ordering::Relaxed); + let otel_running = otel_enabled; #[cfg(feature = "prometheus")] let need_running_counts = metrics_enabled || otel_running; #[cfg(not(feature = "prometheus"))] @@ -2439,8 +2460,18 @@ pub async fn expose_queue_metrics(db: &Pool) { } } + if otel_running { + for q in OTEL_QUEUE_RUNNING_COUNT_TAGS.read().await.iter() { + if queue_running_counts.get(q).is_none() { + otel_set_queue_running_count(q, 0); + } + } + } + #[allow(unused_mut, unused_variables)] let mut running_tags_to_watch: Vec = vec![]; + #[allow(unused_mut, unused_variables)] + let mut otel_running_tags_to_watch: Vec = vec![]; for (tag, count) in &queue_running_counts { #[cfg(feature = "prometheus")] if metrics_enabled { @@ -2451,6 +2482,7 @@ pub async fn expose_queue_metrics(db: &Pool) { if otel_running { otel_set_queue_running_count(tag, *count as i64); + otel_running_tags_to_watch.push(tag.to_string()); } } @@ -2459,6 +2491,10 @@ pub async fn expose_queue_metrics(db: &Pool) { let mut w = QUEUE_RUNNING_COUNT_TAGS.write().await; *w = running_tags_to_watch; } + if otel_running { + let mut w = OTEL_QUEUE_RUNNING_COUNT_TAGS.write().await; + *w = otel_running_tags_to_watch; + } } } From 0fb115304afc49812420e9ce24e5048502621059 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 11:55:47 +0000 Subject: [PATCH 070/153] fix: preserve notes on nodes inside collapsed groups (#8552) * fix: preserve notes on nodes inside collapsed groups Co-Authored-By: Claude Opus 4.6 (1M context) * fix: hide notes for nodes inside collapsed groups instead of repositioning Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../lib/components/graph/FlowGraphV2.svelte | 17 +++++++++++++++-- .../lib/components/graph/noteEditor.svelte.ts | 18 +++++++++++++++++- .../lib/components/graph/noteUtils.svelte.ts | 14 ++++++++++++-- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 47626fb41c..973c7172b3 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -698,6 +698,19 @@ ...aiToolNodesResult.toolNodes ] + // Collect module IDs hidden inside collapsed groups so note cleanup preserves them + const collapsedModuleIds = new Set() + for (const n of finalNodes) { + if (n.type === 'collapsedGroup') { + const modules = (n.data as any)?.modules as FlowModule[] | undefined + if (modules) { + for (const m of modules) { + collapsedModuleIds.add(m.id) + } + } + } + } + // Compute note nodes (no position remapping) let noteNodesResult = showNotes ? computeNoteNodes( @@ -715,7 +728,8 @@ noteManager.render() }, editMode, - noteEditorContext + noteEditorContext, + collapsedModuleIds.size > 0 ? collapsedModuleIds : undefined ) : undefined @@ -921,7 +935,6 @@ document.addEventListener('keydown', globalKeyDownHandler) - return () => { document.removeEventListener('keydown', globalKeyDownHandler) } diff --git a/frontend/src/lib/components/graph/noteEditor.svelte.ts b/frontend/src/lib/components/graph/noteEditor.svelte.ts index e59e3be4f5..50c2df7bfa 100644 --- a/frontend/src/lib/components/graph/noteEditor.svelte.ts +++ b/frontend/src/lib/components/graph/noteEditor.svelte.ts @@ -219,7 +219,10 @@ export class NoteEditor { /** * Clean up group notes using DAG path completion */ - cleanupGroupNotes(flowNodes: { id: string; parentIds?: string[] }[]): void { + cleanupGroupNotes( + flowNodes: { id: string; parentIds?: string[] }[], + collapsedModuleIds?: Set + ): void { if (!this.isAvailable()) { return } @@ -231,6 +234,13 @@ export class NoteEditor { let hasChanges = false const nodeSet = new Set(flowNodes.map((n) => n.id)) + // Include collapsed module IDs as valid — they are hidden but still exist + if (collapsedModuleIds) { + for (const id of collapsedModuleIds) { + nodeSet.add(id) + } + } + // Step 1: Clean invalid nodes from existing group notes for (const note of groupNotes) { const originalIds = note.contained_node_ids || [] @@ -249,6 +259,12 @@ export class NoteEditor { const originalNodes = note.contained_node_ids || [] if (originalNodes.length === 0) continue + // Skip path completion for notes that reference collapsed modules, + // since the DAG is incomplete when groups are collapsed + if (collapsedModuleIds && originalNodes.some((id) => collapsedModuleIds.has(id))) { + continue + } + // Use the DAG path completion and splitting algorithm const completedGroups = completeAndSplitGroup(originalNodes, flowNodes) diff --git a/frontend/src/lib/components/graph/noteUtils.svelte.ts b/frontend/src/lib/components/graph/noteUtils.svelte.ts index bd7fc2ce05..577b421a9e 100644 --- a/frontend/src/lib/components/graph/noteUtils.svelte.ts +++ b/frontend/src/lib/components/graph/noteUtils.svelte.ts @@ -249,7 +249,8 @@ export function computeNoteNodes( noteTextHeights: Record, onTextHeightChange: (noteId: string, height: number) => void, editMode: boolean = false, - noteEditorContext: NoteEditorContext | undefined + noteEditorContext: NoteEditorContext | undefined, + collapsedModuleIds?: Set ): NoteComputeResult { // Check cache first if ( @@ -263,7 +264,7 @@ export function computeNoteNodes( if (editMode) { if (noteEditorContext?.noteEditor?.isAvailable()) { - noteEditorContext.noteEditor.cleanupGroupNotes(nodes) + noteEditorContext.noteEditor.cleanupGroupNotes(nodes, collapsedModuleIds) } } @@ -290,6 +291,15 @@ export function computeNoteNodes( for (const note of notes) { const isGroupNote = note.type === 'group' + + // Skip group notes whose contained nodes are all inside collapsed groups + if (isGroupNote && collapsedModuleIds?.size) { + const ids = note.contained_node_ids ?? [] + if (ids.length > 0 && ids.every((id) => collapsedModuleIds.has(id))) { + continue + } + } + const zIndex = noteZIndexes[note.id] // Calculate position and size using node positions for group notes From ad19ac9b37b04591c921f93f180bdda961af6cef Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:57:47 +0100 Subject: [PATCH 071/153] feat: support multiple folder selection in MCP scope selector (#8557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support multiple folder selection in MCP scope selector Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add per-folder caching for multi-folder runnables loading Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review — workspace prop, length check, empty folder state Co-Authored-By: Claude Opus 4.6 (1M context) * fix: cache folder names per workspace and reload on workspace change Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/mcp/McpScopeSelector.svelte | 97 ++++++++++++++++--- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/components/mcp/McpScopeSelector.svelte b/frontend/src/lib/components/mcp/McpScopeSelector.svelte index 9fe525dd5c..3d019ff539 100644 --- a/frontend/src/lib/components/mcp/McpScopeSelector.svelte +++ b/frontend/src/lib/components/mcp/McpScopeSelector.svelte @@ -5,9 +5,8 @@ import Popover from '$lib/components/Popover.svelte' import MultiSelect from '$lib/components/select/MultiSelect.svelte' import { safeSelectItems } from '$lib/components/select/utils.svelte' - import FolderPicker from '$lib/components/FolderPicker.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' - import { FlowService, IntegrationService, ScriptService } from '$lib/gen' + import { FlowService, FolderService, IntegrationService, ScriptService } from '$lib/gen' import { mcpEndpointTools } from '$lib/mcpEndpointTools' import InfoIcon from 'lucide-svelte/icons/info' import { SvelteMap } from 'svelte/reactivity' @@ -20,7 +19,10 @@ let { workspaceId, scope = $bindable() }: Props = $props() let selectedMode = $state<'favorites' | 'all' | 'folder' | 'custom'>('favorites') - let selectedFolder = $state('') + let selectedFolders = $state([]) + let allFolders = $state([]) + let loadingFolders = $state(false) + let folderNamesCache = new Map() let selectedScripts = $state([]) let selectedFlows = $state([]) let selectedEndpoints = $state([]) @@ -70,8 +72,10 @@ scopeParts.push(`mcp:endpoints:${selectedEndpoints.join(',')}`) } } else if (selectedMode === 'folder') { - const folderPath = `f/${selectedFolder}/*` - scopeParts = [`mcp:scripts:${folderPath}`, `mcp:flows:${folderPath}`, `mcp:endpoints:*`] + const folderPaths = selectedFolders.map((f) => `f/${f}/*`).join(',') + if (selectedFolders.length > 0) { + scopeParts = [`mcp:scripts:${folderPaths}`, `mcp:flows:${folderPaths}`, `mcp:endpoints:*`] + } } else { scopeParts = [`mcp:${selectedMode}`] } @@ -91,13 +95,35 @@ } }) - // Clear folder when not in folder mode + // Clear folders when not in folder mode, load folder names when entering folder mode $effect(() => { - if (selectedMode !== 'folder') { - selectedFolder = '' + if (selectedMode === 'folder' && workspaceId) { + loadFolderNames(workspaceId) + } else { + selectedFolders = [] } }) + async function loadFolderNames(workspace: string) { + if (folderNamesCache.has(workspace)) { + allFolders = folderNamesCache.get(workspace)! + return + } + try { + loadingFolders = true + const excludedFolders = ['app_groups', 'app_custom', 'app_themes'] + const names = ( + await FolderService.listFolderNames({ workspace }) + ).filter((x) => !excludedFolders.includes(x)) + folderNamesCache.set(workspace, names) + allFolders = names + } catch { + allFolders = [] + } finally { + loadingFolders = false + } + } + // Load hub apps on mount async function getAllApps() { if (allApps.length > 0) return @@ -192,11 +218,42 @@ // Load runnables based on mode $effect(() => { if (workspaceId) { - const folderParam = selectedFolder.length > 0 ? selectedFolder : undefined - getScriptsAndFlows(selectedMode === 'favorites', workspaceId, folderParam) + if (selectedMode === 'folder') { + if (selectedFolders.length > 0) { + loadRunnablesForFolders(workspaceId, selectedFolders) + } else { + includedRunnables = [] + } + } else { + getScriptsAndFlows(selectedMode === 'favorites', workspaceId, undefined) + } } }) + async function getCachedRunnables(workspace: string, folder: string): Promise { + const cacheKey = `${workspace}-false-${folder}` + if (runnablesCache.has(cacheKey)) { + return runnablesCache.get(cacheKey) || [] + } + const [scripts, flows] = await Promise.all([ + getScripts(false, workspace, folder), + getFlows(false, workspace, folder) + ]) + const combined = [...scripts, ...flows] + runnablesCache.set(cacheKey, combined) + return combined + } + + async function loadRunnablesForFolders(workspace: string, folders: string[]) { + try { + loadingRunnables = true + const results = await Promise.all(folders.map((f) => getCachedRunnables(workspace, f))) + includedRunnables = [...new Set(results.flat())] + } finally { + loadingRunnables = false + } + } + // Load all scripts/flows for custom mode $effect(() => { if (selectedMode === 'custom' && workspaceId) { @@ -209,7 +266,7 @@ ? 'Create your first scripts or flows to make them available via MCP.' : selectedMode === 'favorites' ? `You do not have any favorite scripts or flows. You can favorite some scripts and flows to include them, or change the scope to "All scripts/flows" to include all your scripts and flows.` - : `You do not have any scripts or flows in the selected folder.` + : `You do not have any scripts or flows in the selected folder(s).` ) function selectAllScripts() { @@ -252,8 +309,8 @@ - Select Folder - + Select Folders + {#if loadingFolders} +
    Loading folders...
    + {:else} + + {/if}
    {/if} @@ -389,7 +454,7 @@
    {/if} - {:else if selectedMode !== 'folder' || selectedFolder.length > 0} + {:else if selectedMode !== 'folder' || selectedFolders.length > 0} {#if loadingRunnables}
    Date: Fri, 27 Mar 2026 11:58:24 +0000 Subject: [PATCH 072/153] perf: enable bun bundle caching for WAC v2 scripts (#8556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAC v2 scripts previously disabled bundle caching, forcing every execution to resolve windmill-client from node_modules at runtime (~74ms overhead per bun launch). This makes both the prebundle and execution paths WAC-aware by including WorkflowCtx/StepSuspend/setWorkflowCtx re-exports in the bundle, so the wrapper can import them from the cached bundle instead of node_modules. Benchmarked improvement: wac_inline_2 12→38 wf/s (3.2x), wac_seq_2 6→17 wf/s (2.8x) with no regression on plain bun scripts or flows. Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-worker/src/bun_executor.rs | 62 +++++++++++---------- backend/windmill-worker/src/wac_executor.rs | 17 ++++++ 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 6bc6cacc32..433f96bcec 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1092,7 +1092,12 @@ pub async fn prebundle_bun_script( } let origin = format!("{job_dir}/main.js"); - write_file(job_dir, "main.ts", &remove_pinned_imports(inner_content)?)?; + let mut content = remove_pinned_imports(inner_content)?; + if crate::wac_executor::is_wac_v2_ts(inner_content) { + content = crate::wac_executor::inject_wac_task_names(&content); + content = format!("export {{ WorkflowCtx, StepSuspend, setWorkflowCtx }} from \"windmill-client\";\n{content}"); + } + write_file(job_dir, "main.ts", &content)?; build_loader( job_dir, base_internal_url, @@ -1318,29 +1323,12 @@ pub async fn handle_bun_job( // Also handles: export const, let, var, and optional generic type parameters. // Skips calls that already have a string argument: `task("path", async ...` let inner_content = if is_wac_v2 { - use regex::Regex; - use std::borrow::Cow; - lazy_static::lazy_static! { - static ref TASK_RE: Regex = - Regex::new(r#"(?m)((?:export\s+)?(?:const|let|var)\s+)(\w+)(\s*=\s*task\s*(?:<[^>]*>)?\s*\(\s*)(async\b)"#).unwrap(); - } - let replaced = TASK_RE.replace_all(inner_content, r#"${1}${2}${3}"${2}", ${4}"#); - match replaced { - Cow::Borrowed(_) => inner_content.to_string(), - Cow::Owned(s) => s, - } + crate::wac_executor::inject_wac_task_names(inner_content) } else { inner_content.to_string() }; let inner_content = inner_content.as_str(); - // WAC v2 scripts can't use bundle caching because the wrapper imports - // windmill-client from node_modules, which isn't available in bundle mode - if is_wac_v2 && has_bundle_cache { - has_bundle_cache = false; - let _ = write_file(job_dir, "main.ts", inner_content)?; - } - let mut format = BundleFormat::Cjs; if has_bundle_cache { let target; @@ -1561,6 +1549,12 @@ pub async fn handle_bun_job( "./main.ts" }; + let wac_client_import = if has_bundle_cache { + "./main.js" + } else { + "windmill-client" + }; + let preprocessor = if let Some(pre_args) = pre_args { let pre_spread = pre_args.into_iter().map(|x| x.name).join(","); format!( @@ -1588,7 +1582,7 @@ pub async fn handle_bun_job( format!( r#" import * as Main from "{main_import}"; -import {{ WorkflowCtx, StepSuspend, setWorkflowCtx }} from "windmill-client"; +import {{ WorkflowCtx, StepSuspend, setWorkflowCtx }} from "{wac_client_import}"; import * as fs from "fs/promises"; @@ -1779,7 +1773,6 @@ try {{ && !annotation.nobundling && !*DISABLE_BUNDLING && !codebase.is_some() - && !is_wac_v2 && (maybe_lock.get_lock().is_some() || annotation.native); let write_loader_f = async { @@ -1844,6 +1837,17 @@ try {{ } } + // Prepend WAC re-exports to main.ts so the bundle includes WorkflowCtx etc. + if build_cache && is_wac_v2 { + let main_path = format!("{job_dir}/main.ts"); + let current = read_file_content(&main_path).await?; + write_file( + job_dir, + "main.ts", + &format!("export {{ WorkflowCtx, StepSuspend, setWorkflowCtx }} from \"windmill-client\";\n{current}"), + )?; + } + if !codebase.is_some() && !has_bundle_cache { if build_cache { generate_bun_bundle( @@ -1882,14 +1886,14 @@ try {{ } if !annotation.native { let ex_wrapper = read_file_content(&format!("{job_dir}/wrapper.mjs")).await?; - write_file( - job_dir, - "wrapper.mjs", - &ex_wrapper.replace( - "import * as Main from \"./main.ts\"", - "import * as Main from \"./main.js\"", - ), - )?; + let mut rewritten = ex_wrapper.replace( + "import * as Main from \"./main.ts\"", + "import * as Main from \"./main.js\"", + ); + if is_wac_v2 { + rewritten = rewritten.replace("from \"windmill-client\"", "from \"./main.js\""); + } + write_file(job_dir, "wrapper.mjs", &rewritten)?; write_file(job_dir, "package.json", r#"{ "type": "module" }"#)?; } fs::remove_file(format!("{job_dir}/main.ts"))?; diff --git a/backend/windmill-worker/src/wac_executor.rs b/backend/windmill-worker/src/wac_executor.rs index 9b4ba3d92a..28de226102 100644 --- a/backend/windmill-worker/src/wac_executor.rs +++ b/backend/windmill-worker/src/wac_executor.rs @@ -364,6 +364,23 @@ pub fn is_wac_v2_ts(code: &str) -> bool { has_wac_import && has_workflow } +/// Inject the variable name as the first argument to `task()` calls in WAC v2 scripts. +/// `const double = task(async ...` → `const double = task("double", async ...` +/// Skips calls that already have a string argument. +pub fn inject_wac_task_names(content: &str) -> String { + use regex::Regex; + use std::borrow::Cow; + lazy_static::lazy_static! { + static ref TASK_RE: Regex = + Regex::new(r#"(?m)((?:export\s+)?(?:const|let|var)\s+)(\w+)(\s*=\s*task\s*(?:<[^>]*>)?\s*\(\s*)(async\b)"#).unwrap(); + } + let replaced = TASK_RE.replace_all(content, r#"${1}${2}${3}"${2}", ${4}"#); + match replaced { + Cow::Borrowed(_) => content.to_string(), + Cow::Owned(s) => s, + } +} + /// Detect WAC v2 patterns in Python code. /// Checks for `@workflow` decorator and `@task` decorator with wmill import, /// skipping comment lines. From 2f326758013dd1f1e6ae732e5784a32f1fb6e4bd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 11:59:17 +0000 Subject: [PATCH 073/153] feat: DB-coordinated graceful restart staggering for settings changes (#8555) * feat: add DB-coordinated graceful restart staggering for settings changes Co-Authored-By: Claude Opus 4.6 (1M context) * fix: preserve original instance names in restart coordination record Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove randomness, add drain delay for in-flight requests Co-Authored-By: Claude Opus 4.6 (1M context) * fix: spawn restart in background, deduplicate entries, clarify stale filter Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/src/main.rs | 174 ++++++++++++++++-- .../windmill-common/src/global_settings.rs | 1 + .../windmill-common/src/instance_config.rs | 1 + 3 files changed, 157 insertions(+), 19 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index 4fe22c517f..18ff643621 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -52,9 +52,10 @@ use windmill_common::{ NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING, - REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, - RUBY_REPOS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, TEAMS_SETTING, - TIMEOUT_WAIT_RESULT_SETTING, UV_INDEX_STRATEGY_SETTING, WORKSPACE_REGISTRIES_SETTING, + REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, + RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, + SCIM_TOKEN_SETTING, SMTP_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, + UV_INDEX_STRATEGY_SETTING, WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -67,7 +68,7 @@ use windmill_common::{ is_native_mode_from_env, reload_custom_tags_setting, Connection, HUB_CACHE_DIR, HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_LOGS_DIR, WINDMILL_DIR, WORKER_GROUP, }, - KillpillSender, DEFAULT_HUB_BASE_URL, METRICS_ENABLED, + KillpillSender, DEFAULT_HUB_BASE_URL, INSTANCE_NAME, METRICS_ENABLED, }; #[cfg(feature = "enterprise")] @@ -1791,7 +1792,8 @@ async fn process_notify_event( reload_otel_tracing_proxy_setting(conn).await; if worker_mode { tracing::info!("OTEL tracing proxy setting changed, restarting worker"); - send_delayed_killpill(tx, 4, "OTEL tracing proxy setting change").await; + spawn_graceful_killpill(tx, db, 10, "OTEL tracing proxy setting change") + .await; } } REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => { @@ -1799,12 +1801,12 @@ async fn process_notify_event( } EXPOSE_METRICS_SETTING => { tracing::info!("Metrics setting changed, restarting"); - send_delayed_killpill(tx, 40, "metrics setting change").await; + spawn_graceful_killpill(tx, db, 10, "metrics setting change").await; } EMAIL_DOMAIN_SETTING => { tracing::info!("Email domain setting changed"); if server_mode { - send_delayed_killpill(tx, 4, "email domain setting change").await; + spawn_graceful_killpill(tx, db, 10, "email domain setting change").await; } } EXPOSE_DEBUG_METRICS_SETTING => { @@ -1840,19 +1842,19 @@ async fn process_notify_event( } OTEL_SETTING => { tracing::info!("OTEL setting changed, restarting"); - send_delayed_killpill(tx, 4, "OTEL setting change").await; + spawn_graceful_killpill(tx, db, 10, "OTEL setting change").await; } REQUEST_SIZE_LIMIT_SETTING => { if server_mode { tracing::info!("Request limit size change detected, killing server expecting to be restarted"); - send_delayed_killpill(tx, 4, "request size limit change").await; + spawn_graceful_killpill(tx, db, 10, "request size limit change").await; } } SAML_METADATA_SETTING => { tracing::info!( "SAML metadata change detected, killing server expecting to be restarted" ); - send_delayed_killpill(tx, 0, "SAML metadata change").await; + spawn_graceful_killpill(tx, db, 10, "SAML metadata change").await; } HUB_BASE_URL_SETTING => { if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await { @@ -1901,6 +1903,9 @@ async fn process_notify_event( .unwrap_or(false); tracing::info!("Workspace telemetry setting changed: enabled={}", enabled); } + RESTART_COORDINATION_SETTING => { + // Internal coordination key for staggered restarts, no action needed + } _ => { tracing::info!("Unrecognized Global Setting Change Payload: {:?}", payload); } @@ -2042,14 +2047,145 @@ pub async fn run_workers( Ok(()) } -async fn send_delayed_killpill(tx: &KillpillSender, mut max_delay_secs: u64, context: &str) { - if max_delay_secs == 0 { - max_delay_secs = 1; - } - // Random delay to avoid all servers/workers shutting down simultaneously - let rd_delay = rand::rng().random_range(0..max_delay_secs); - tracing::info!("Scheduling {context} shutdown in {rd_delay}s"); - tokio::time::sleep(Duration::from_secs(rd_delay)).await; +/// Schedule a graceful restart with DB-coordinated staggering. +/// +/// Uses a PostgreSQL advisory lock to serialize restart scheduling across server instances. +/// Each instance records its planned restart time in the `_restart_coordination` global setting; +/// subsequent instances read existing schedules and shift their restart to maintain at least +/// `safety_margin_secs` between consecutive restarts (must exceed the server startup time). +/// +/// Every server waits at least `DRAIN_DELAY_SECS` to let in-flight requests complete. +/// Each subsequent server waits an additional `safety_margin_secs` after the previous one, +/// guaranteeing zero downtime overlap. +/// +/// The DB coordination is done synchronously (fast, ~ms) to reserve our restart slot, +/// then the sleep+kill is spawned in the background so the notification handler is not blocked. +/// +/// Falls back to drain-only delay if DB coordination fails. +async fn spawn_graceful_killpill( + tx: &KillpillSender, + db: &Pool, + safety_margin_secs: u64, + context: &str, +) { + // Minimum delay before any restart to let in-flight requests drain + const DRAIN_DELAY_SECS: u64 = 3; - tx.send(); + let delay = match coordinate_restart_delay(db, safety_margin_secs, DRAIN_DELAY_SECS).await { + Ok(d) => d, + Err(e) => { + tracing::warn!( + "Failed to coordinate restart for {context}: {e:#}, \ + falling back to drain delay of {DRAIN_DELAY_SECS}s" + ); + DRAIN_DELAY_SECS + } + }; + + tracing::info!("Scheduling {context} graceful shutdown in {delay}s"); + let tx = tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(delay)).await; + tx.send(); + }); +} + +/// Coordinate a restart delay with other instances via the DB. +/// +/// Returns the delay (in seconds from now) at which this instance should restart. +/// The first server gets `drain_delay_secs` (to let in-flight requests complete). +/// Each subsequent server is spaced `safety_margin_secs` after the latest scheduled restart. +async fn coordinate_restart_delay( + db: &Pool, + safety_margin_secs: u64, + drain_delay_secs: u64, +) -> anyhow::Result { + const RESTART_LOCK_ID: i64 = 737_483_920; + // Stale threshold: ignore coordination entries older than this + const STALE_THRESHOLD_SECS: i64 = 120; + + let now = chrono::Utc::now(); + + let mut tx = db.begin().await.context("begin restart coordination tx")?; + + // Serialize access across all instances + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(RESTART_LOCK_ID) + .execute(&mut *tx) + .await + .context("acquire restart coordination lock")?; + + // Read existing coordination record + let existing: Option = + sqlx::query_scalar("SELECT value FROM global_settings WHERE name = $1") + .bind(RESTART_COORDINATION_SETTING) + .fetch_optional(&mut *tx) + .await + .context("read restart coordination")?; + + // Parse existing scheduled restarts, filtering out stale entries + // Each entry is (instance_name, restart_at) + let mut scheduled: Vec<(String, chrono::DateTime)> = Vec::new(); + if let Some(val) = &existing { + if let Some(arr) = val.get("restarts").and_then(|v| v.as_array()) { + for entry in arr { + let instance = entry + .get("instance") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + if let Some(ts_str) = entry.get("restart_at").and_then(|v| v.as_str()) { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) { + let dt = dt.with_timezone(&chrono::Utc); + let stale_cutoff = now - chrono::Duration::seconds(STALE_THRESHOLD_SECS); + if dt > stale_cutoff { + scheduled.push((instance, dt)); + } + } + } + } + } + } + + // Find the latest scheduled restart + let latest = scheduled.iter().map(|(_, dt)| *dt).max(); + let earliest_allowed = now + chrono::Duration::seconds(drain_delay_secs as i64); + + // Our restart time: drain_delay from now, or safety_margin after the latest existing restart + let our_restart = match latest { + Some(last) => { + let after_last = last + chrono::Duration::seconds(safety_margin_secs as i64); + // Use whichever is later: drain delay or staggered position + earliest_allowed.max(after_last) + } + None => earliest_allowed, + }; + + // Record our restart time (deduplicate: remove any prior entry for this instance) + scheduled.retain(|(inst, _)| inst != &*INSTANCE_NAME); + scheduled.push((INSTANCE_NAME.clone(), our_restart)); + let new_value = serde_json::json!({ + "restarts": scheduled.iter().map(|(inst, dt)| { + serde_json::json!({ + "instance": inst, + "restart_at": dt.to_rfc3339() + }) + }).collect::>() + }); + + sqlx::query( + "INSERT INTO global_settings (name, value, updated_at) \ + VALUES ($1, $2, now()) \ + ON CONFLICT (name) DO UPDATE SET value = $2, updated_at = now()", + ) + .bind(RESTART_COORDINATION_SETTING) + .bind(&new_value) + .execute(&mut *tx) + .await + .context("write restart coordination")?; + + tx.commit().await.context("commit restart coordination")?; + + let delay = (our_restart - now).num_seconds().max(0) as u64; + Ok(delay) } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 31aadb8210..bdedcbd7bb 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -64,6 +64,7 @@ pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version"; pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; pub const INSTANCE_EVENTS_WEBHOOK_SETTING: &str = "instance_events_webhook"; pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries"; +pub const RESTART_COORDINATION_SETTING: &str = "_restart_coordination"; use std::sync::Arc; use tokio::sync::RwLock; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index c843cc6621..dfa0dd6454 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -870,6 +870,7 @@ pub const HIDDEN_SETTINGS: &[&str] = &[ "uid", "min_keep_alive_version", "automate_username_creation", + "_restart_coordination", ]; /// Top-level settings whose entire value is sensitive and must be fully redacted in logs. From 8df1d8ec17737ea54a1422150572c77e3943b32f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 12:28:54 +0000 Subject: [PATCH 074/153] test nits --- backend/Cargo.lock | 3 ++- backend/windmill-api-integration-tests/tests/health.rs | 1 - backend/windmill-api-integration-tests/tests/jobs_authed.rs | 1 + .../tests/workspace_dependencies_git_sync.rs | 6 ++++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index df09b225b5..11c743a643 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14606,7 +14606,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.11.1", + "indexmap 2.12.0", "pin-project-lite", "slab", "sync_wrapper", @@ -16253,6 +16253,7 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-sqs", + "axum 0.8.4", "base64 0.22.1", "futures", "rand 0.9.0", diff --git a/backend/windmill-api-integration-tests/tests/health.rs b/backend/windmill-api-integration-tests/tests/health.rs index c804373697..f624431510 100644 --- a/backend/windmill-api-integration-tests/tests/health.rs +++ b/backend/windmill-api-integration-tests/tests/health.rs @@ -1,4 +1,3 @@ -use serde_json::json; use sqlx::{Pool, Postgres}; use windmill_test_utils::*; diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs index 4e82a4aba1..766c300d3d 100644 --- a/backend/windmill-api-integration-tests/tests/jobs_authed.rs +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -46,6 +46,7 @@ async fn insert_completed_job(db: &Pool) -> Uuid { id } +#[allow(dead_code)] async fn create_script(port: u16) -> String { let base = format!("http://localhost:{port}/api/w/test-workspace/scripts"); let resp = authed(client().post(format!("{base}/create"))) diff --git a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs index 72a102dfd8..9226d3f74a 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs @@ -14,6 +14,7 @@ use serde_json::json; use sqlx::{Pool, Postgres}; use std::time::Duration; +#[allow(unused_imports)] use windmill_test_utils::*; /// Row shape for querying deployment callback jobs from v2_job_queue @@ -27,6 +28,7 @@ struct DeploymentCallbackJob { } /// Poll for deployment callback jobs in the queue for a given script path +#[allow(dead_code)] async fn get_deployment_callback_jobs( db: &Pool, script_path: &str, @@ -63,6 +65,7 @@ async fn get_deployment_callback_jobs( } /// Configure git sync for the test workspace with workspace dependencies enabled +#[allow(dead_code)] async fn setup_git_sync_config(db: &Pool, sync_script_path: &str) -> anyhow::Result<()> { let git_sync_config = json!({ "include_type": ["workspacedependencies"], @@ -87,6 +90,7 @@ async fn setup_git_sync_config(db: &Pool, sync_script_path: &str) -> a } /// Create a git repository resource for testing +#[allow(dead_code)] async fn create_git_repo_resource(db: &Pool) -> anyhow::Result<()> { sqlx::query( r#" @@ -107,6 +111,7 @@ async fn create_git_repo_resource(db: &Pool) -> anyhow::Result<()> { } /// Create a dummy sync script for testing (with version >= 28103 for debouncing support) +#[allow(dead_code)] async fn create_sync_script(db: &Pool, path: &str) -> anyhow::Result { let hash: i64 = rand::random::().unsigned_abs() as i64; sqlx::query( @@ -126,6 +131,7 @@ async fn create_sync_script(db: &Pool, path: &str) -> anyhow::Result, name: &str) -> anyhow::Result<()> { sqlx::query( r#" From 70f3ee5ed4470e9993be822874f2b38e83a96611 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:21:42 +0100 Subject: [PATCH 075/153] fix: use admin db pool in get_copilot_settings_state (#8564) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-api-workspaces/src/workspaces.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index fe7991d3a8..08b9d4d3d6 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -652,25 +652,23 @@ async fn get_settings( } async fn get_copilot_settings_state( - authed: ApiAuthed, + _authed: ApiAuthed, Path(w_id): Path, - Extension(user_db): Extension, + Extension(db): Extension, ) -> JsonResult { - let mut tx = user_db.begin(&authed).await?; let workspace_ai_config = sqlx::query_scalar!( "SELECT ai_config FROM workspace_settings WHERE workspace_id = $1", &w_id ) - .fetch_optional(&mut *tx) + .fetch_optional(&db) .await .map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?; let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?; let instance_ai_config: Option = sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'") - .fetch_optional(&mut *tx) + .fetch_optional(&db) .await .map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?; - tx.commit().await?; Ok(Json(build_copilot_settings_state( has_ai_providers(workspace_ai_config.as_ref()), From 5fd2c1a1292afe2b52f7a5e90c98e79255abd830 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:13:33 +0100 Subject: [PATCH 076/153] chore(cli): separate unit tests from integration tests and fix test cleanup (#8562) * fix(cli): separate unit tests from integration tests and fix test cleanup - Rename 14 non-backend test files to *_unit.test.ts convention - Add UNIT_ONLY env var guard in setup.ts to skip cargo build/backend startup - Add test:unit and test:integration scripts to package.json - Use setsid on Linux for process group management so stop() kills both cargo and the windmill child process - Fix exit handler to kill process group instead of just the direct child - Add cleanupStaleTestResources() to drop orphaned windmill_test_* databases and kill orphaned backend processes on startup - Rewrite TESTING.md with current bun-based instructions Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): fix process group approach - kill by db name instead of setsid The setsid approach didn't work because setsid forks, making the PID we get from Bun.spawn ephemeral. Instead, kill orphaned windmill child processes by matching our unique database name in /proc/pid/environ. Also add afterAll hook in setup.ts so full async cleanup (process kill + database drop) runs when all tests complete normally, not just on SIGINT/SIGTERM. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): address PR review feedback - Remove duplicate cleanupStaleTestResources() call in getTestBackend() (already called in setup.ts) - Add regex guard on database names before SQL interpolation - Extract shared killWindmillProcessesByEnvMatch() helper to deduplicate process-killing logic - Remove redundant test:integration script (test already runs everything) - Flip setup.ts to if/else pattern for readability Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/TESTING.md | 88 +++++++-------- cli/package.json | 1 + cli/test/cargo_backend.ts | 101 +++++++++++++++++- ...t.ts => conf_branch_override_unit.test.ts} | 0 ...ments_to_map_branch_specific_unit.test.ts} | 0 ...test.ts => generate_metadata_unit.test.ts} | 0 ...ate.test.ts => init_template_unit.test.ts} | 0 ...scripts_failure_preprocessor_unit.test.ts} | 0 ...mand.test.ts => lint_command_unit.test.ts} | 0 ..._locks.test.ts => lint_locks_unit.test.ts} | 0 ..._cache.test.ts => lock_cache_unit.test.ts} | 0 ...t.ts => replace_path_scripts_unit.test.ts} | 0 ...es.test.ts => script_modules_unit.test.ts} | 0 cli/test/setup.ts | 30 +++++- ...ms.test.ts => specific_items_unit.test.ts} | 0 ...tion.test.ts => tar_creation_unit.test.ts} | 0 cli/test/test_backend.ts | 15 ++- ...l_lock.test.ts => wmill_lock_unit.test.ts} | 0 ...st.ts => workspace_conflicts_unit.test.ts} | 0 19 files changed, 181 insertions(+), 54 deletions(-) rename cli/test/{conf_branch_override.test.ts => conf_branch_override_unit.test.ts} (100%) rename cli/test/{elements_to_map_branch_specific.test.ts => elements_to_map_branch_specific_unit.test.ts} (100%) rename cli/test/{generate_metadata.test.ts => generate_metadata_unit.test.ts} (100%) rename cli/test/{init_template.test.ts => init_template_unit.test.ts} (100%) rename cli/test/{inline_scripts_failure_preprocessor.test.ts => inline_scripts_failure_preprocessor_unit.test.ts} (100%) rename cli/test/{lint_command.test.ts => lint_command_unit.test.ts} (100%) rename cli/test/{lint_locks.test.ts => lint_locks_unit.test.ts} (100%) rename cli/test/{lock_cache.test.ts => lock_cache_unit.test.ts} (100%) rename cli/test/{replace_path_scripts.test.ts => replace_path_scripts_unit.test.ts} (100%) rename cli/test/{script_modules.test.ts => script_modules_unit.test.ts} (100%) rename cli/test/{specific_items.test.ts => specific_items_unit.test.ts} (100%) rename cli/test/{tar_creation.test.ts => tar_creation_unit.test.ts} (100%) rename cli/test/{wmill_lock.test.ts => wmill_lock_unit.test.ts} (100%) rename cli/test/{workspace_conflicts.test.ts => workspace_conflicts_unit.test.ts} (100%) diff --git a/cli/TESTING.md b/cli/TESTING.md index 9928e75266..542baab368 100644 --- a/cli/TESTING.md +++ b/cli/TESTING.md @@ -3,57 +3,57 @@ ## Running Tests ```bash -# Run all tests -deno test -A --no-check test/ +# Run unit tests only (fast — no backend, no database, no cargo build) +bun run test:unit + +# Run all tests (unit + integration — requires PostgreSQL + cargo) +DATABASE_URL=postgres://postgres:changeme@localhost:5432 bun run test # Run specific test files -deno test -A --no-check test/gitsync_settings_features.test.ts -deno test -A --no-check test/init_no_git_sync.test.ts -deno test -A --no-check test/multi_instance_workspace.test.ts -deno test -A --no-check test/override_settings_behavior.test.ts -deno test -A --no-check test/sync_config_resolution.test.ts -deno test -A --no-check test/workspace_conflicts.test.ts - -# Run with specific test patterns -deno test -A --no-check test/ --filter "workspace" -deno test -A --no-check test/ --filter "sync" +bun test test/sync_pull_push.test.ts +bun test test/workspace_conflicts_unit.test.ts ``` -## Test Files +## Test Categories -- **`gitsync_settings_features.test.ts`** - Git sync settings functionality -- **`init_no_git_sync.test.ts`** - Init without git sync -- **`multi_instance_workspace.test.ts`** - Multi-instance workspace handling -- **`override_settings_behavior.test.ts`** - Settings override behavior -- **`sync_config_resolution.test.ts`** - Sync configuration resolution -- **`workspace_conflicts.test.ts`** - Workspace conflict detection +### Unit tests (`*_unit.test.ts`) -## Docker Requirements +Pure local tests — no backend, no database. Uses `bunfig.unit.toml` (no preload). + +Examples: `git_unit`, `lint_command_unit`, `tar_creation_unit`, `workspace_conflicts_unit` + +### Integration tests + +Require a running backend and PostgreSQL. The `setup.ts` preload builds the backend +binary and starts a shared backend instance. + +Examples: `sync_pull_push`, `dev_server`, `standalone_commands` + +## Environment Variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `DATABASE_URL` | PostgreSQL connection string (without database name) | `postgres://postgres:changeme@localhost:5432` | +| `TEST_BACKEND` | `cargo` or `docker` | `cargo` | +| `CI_MINIMAL_FEATURES` | `true` for CI mode (zip-only features) | unset | +| `EE_LICENSE_KEY` | Enterprise license for EE feature tests | unset | +| `TEST_FEATURES` | Additional cargo features (comma-separated) | unset | +| `TEST_CLI_RUNTIME` | `node` to test npm package | unset | +| `UNIT_ONLY` | `1` to skip backend setup in preload (used by `test:unit`) | unset | +| `VERBOSE` | `1` for backend process output | unset | + +## Cleanup + +Stale test databases (`windmill_test_*`) and orphaned backend processes from +previous crashed runs are automatically cleaned up when starting a new test run. + +To manually check for leftovers: ```bash -# Ensure Docker is running -docker --version -docker-compose --version +# Check for stale test databases +psql postgres://postgres:changeme@localhost:5432/postgres -c \ + "SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%';" -# Ensure EE license key is available -echo $EE_LICENSE_KEY +# Check for orphaned backend processes +ps aux | grep "target/debug/windmill" | grep -v grep ``` - -## Debugging Failed Tests - -```bash -# Run with verbose output -deno test -A --no-check test/ --reporter=verbose - -# Check container status -docker ps - -# View backend logs -docker logs test-test_windmill_server-1 - -# Manual container management -cd test -docker compose -f docker-compose.test.yml up -d -docker compose -f docker-compose.test.yml down -docker compose -f docker-compose.test.yml down -v -``` \ No newline at end of file diff --git a/cli/package.json b/cli/package.json index e44a215631..105915a720 100644 --- a/cli/package.json +++ b/cli/package.json @@ -9,6 +9,7 @@ "dev": "bun run src/main.ts", "build": "./build.sh", "test": "bun test test/", + "test:unit": "UNIT_ONLY=1 bun test test/*_unit*", "check": "bunx tsc --noEmit", "gen-client": "./gen_wm_client.sh && ./windmill-utils-internal/gen_wm_client.sh" }, diff --git a/cli/test/cargo_backend.ts b/cli/test/cargo_backend.ts index a25a788f7e..692fc1619b 100644 --- a/cli/test/cargo_backend.ts +++ b/cli/test/cargo_backend.ts @@ -14,11 +14,13 @@ import { resolve, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { statSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { createServer } from "node:net"; import { Subprocess } from "bun"; +const IS_LINUX = process.platform === "linux"; + export interface CargoBackendConfig { /** PostgreSQL connection string (without database name) */ postgresUrl?: string; @@ -193,6 +195,10 @@ export class CargoBackend { this.process = null; } + // Kill any child processes (e.g. the windmill binary spawned by cargo) + // by matching our unique database name in their environment + await this.killProcessesByDbName(); + // Drop the test database await this.dropDatabase(); @@ -304,6 +310,15 @@ export class CargoBackend { } } + /** + * Kill any processes whose environment contains our unique database name. + * This catches child processes (e.g. the windmill binary spawned by cargo run) + * that survive after the direct child is killed. + */ + private async killProcessesByDbName(): Promise { + await killWindmillProcessesByEnvMatch(this.dbName); + } + /** * Start the backend process using cargo run */ @@ -762,6 +777,90 @@ export class CargoBackend { } } +/** + * Kill windmill processes whose /proc/pid/environ contains the given pattern. + * Used by both per-test cleanup (match specific DB name) and stale cleanup (match any test DB). + */ +async function killWindmillProcessesByEnvMatch(pattern: string): Promise { + if (!IS_LINUX) return; + try { + const pgrepProc = Bun.spawn(["pgrep", "-f", "target/(debug|release)/windmill"], { + stdout: "pipe", stderr: "pipe", + }); + const output = await new Response(pgrepProc.stdout).text(); + await new Response(pgrepProc.stderr).text(); + await pgrepProc.exited; + + for (const pidStr of output.trim().split("\n").filter(Boolean)) { + const pid = Number(pidStr); + if (isNaN(pid)) continue; + try { + const environ = await readFile(`/proc/${pid}/environ`, "utf-8"); + if (environ.includes(pattern)) { + console.log(`Killing orphaned test backend process: ${pid}`); + process.kill(pid, "SIGKILL"); + } + } catch { + // Process exited or we lack permissions + } + } + } catch { + // pgrep not available or no matches + } +} + +/** + * Clean up stale test databases and orphaned backend processes from previous + * test runs that crashed or were killed without proper cleanup. + * + * Should be called before starting a new test backend. + */ +export async function cleanupStaleTestResources(postgresUrl?: string): Promise { + const baseUrl = postgresUrl || process.env["DATABASE_URL"] || "postgres://postgres:changeme@localhost:5432"; + const url = new URL(baseUrl); + url.pathname = ""; + url.search = ""; + const cleanBaseUrl = url.toString().replace(/\/$/, ""); + + // 1. Find and drop stale windmill_test_* databases + try { + const listProc = Bun.spawn(["psql", `${cleanBaseUrl}/postgres`, "-t", "-c", + `SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%';` + ], { stdout: "pipe", stderr: "pipe" }); + const output = await new Response(listProc.stdout).text(); + await new Response(listProc.stderr).text(); + await listProc.exited; + + const staleDBs = output.trim().split("\n").map(s => s.trim()).filter(Boolean); + for (const db of staleDBs) { + // Only touch databases matching the expected naming pattern + if (!/^windmill_test_[a-z0-9_]+$/.test(db)) continue; + console.log(`Cleaning up stale test database: ${db}`); + const termProc = Bun.spawn(["psql", `${cleanBaseUrl}/postgres`, "-c", + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${db}' AND pid <> pg_backend_pid();` + ], { stdout: "pipe", stderr: "pipe" }); + await new Response(termProc.stdout).text(); + await new Response(termProc.stderr).text(); + await termProc.exited; + + const dropProc = Bun.spawn(["psql", `${cleanBaseUrl}/postgres`, "-c", + `DROP DATABASE IF EXISTS "${db}";` + ], { stdout: "pipe", stderr: "pipe" }); + await new Response(dropProc.stdout).text(); + await new Response(dropProc.stderr).text(); + await dropProc.exited; + } + if (staleDBs.length > 0) { + console.log(`Cleaned up ${staleDBs.length} stale test database(s)`); + } + } catch (err) { + console.warn(`Warning: Failed to clean up stale databases: ${err}`); + } + + // 2. Find and kill orphaned windmill processes from test runs + await killWindmillProcessesByEnvMatch("windmill_test_"); +} + // Global backend instance let globalCargoBackend: CargoBackend | null = null; diff --git a/cli/test/conf_branch_override.test.ts b/cli/test/conf_branch_override_unit.test.ts similarity index 100% rename from cli/test/conf_branch_override.test.ts rename to cli/test/conf_branch_override_unit.test.ts diff --git a/cli/test/elements_to_map_branch_specific.test.ts b/cli/test/elements_to_map_branch_specific_unit.test.ts similarity index 100% rename from cli/test/elements_to_map_branch_specific.test.ts rename to cli/test/elements_to_map_branch_specific_unit.test.ts diff --git a/cli/test/generate_metadata.test.ts b/cli/test/generate_metadata_unit.test.ts similarity index 100% rename from cli/test/generate_metadata.test.ts rename to cli/test/generate_metadata_unit.test.ts diff --git a/cli/test/init_template.test.ts b/cli/test/init_template_unit.test.ts similarity index 100% rename from cli/test/init_template.test.ts rename to cli/test/init_template_unit.test.ts diff --git a/cli/test/inline_scripts_failure_preprocessor.test.ts b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts similarity index 100% rename from cli/test/inline_scripts_failure_preprocessor.test.ts rename to cli/test/inline_scripts_failure_preprocessor_unit.test.ts diff --git a/cli/test/lint_command.test.ts b/cli/test/lint_command_unit.test.ts similarity index 100% rename from cli/test/lint_command.test.ts rename to cli/test/lint_command_unit.test.ts diff --git a/cli/test/lint_locks.test.ts b/cli/test/lint_locks_unit.test.ts similarity index 100% rename from cli/test/lint_locks.test.ts rename to cli/test/lint_locks_unit.test.ts diff --git a/cli/test/lock_cache.test.ts b/cli/test/lock_cache_unit.test.ts similarity index 100% rename from cli/test/lock_cache.test.ts rename to cli/test/lock_cache_unit.test.ts diff --git a/cli/test/replace_path_scripts.test.ts b/cli/test/replace_path_scripts_unit.test.ts similarity index 100% rename from cli/test/replace_path_scripts.test.ts rename to cli/test/replace_path_scripts_unit.test.ts diff --git a/cli/test/script_modules.test.ts b/cli/test/script_modules_unit.test.ts similarity index 100% rename from cli/test/script_modules.test.ts rename to cli/test/script_modules_unit.test.ts diff --git a/cli/test/setup.ts b/cli/test/setup.ts index 7eecd8bf2d..7f27240220 100644 --- a/cli/test/setup.ts +++ b/cli/test/setup.ts @@ -1,14 +1,22 @@ /** * Global test setup — preloaded before all test files. * + * When UNIT_ONLY=1, skips all backend setup (cargo build, database, etc.) + * so that unit tests can run instantly without any external dependencies. + * + * Otherwise: * 1. Builds the backend binary so `cargo run` starts instantly. * 2. Starts a shared backend instance so integration tests don't * bear the startup cost inside their per-test timeout window. */ -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { statSync } from "node:fs"; +if (process.env["UNIT_ONLY"]) { + // Nothing to do — unit tests don't need backend setup +} else { + +const { resolve } = await import("node:path"); +const { fileURLToPath } = await import("node:url"); +const { statSync } = await import("node:fs"); const __dirname = resolve(fileURLToPath(import.meta.url), ".."); @@ -69,10 +77,22 @@ console.log("Backend build complete."); // This avoids the first integration test timing out while the backend // creates its database, starts the process, and waits for the health check. if (process.env["DATABASE_URL"]) { - const { getTestBackend } = await import("./test_backend.ts"); + // Clean up any stale databases/processes from previous crashed test runs + const { cleanupStaleTestResources } = await import("./cargo_backend.ts"); + await cleanupStaleTestResources(); + + const { getTestBackend, cleanupTestBackend } = await import("./test_backend.ts"); console.log("Pre-starting test backend..."); await getTestBackend(); console.log("Test backend is ready for all tests."); + + // Register afterAll to do full async cleanup (kill processes + drop DB) + // when all tests complete. The synchronous "exit" handler alone can't + // drop databases or scan /proc for orphaned child processes. + const { afterAll } = await import("bun:test"); + afterAll(async () => { + await cleanupTestBackend(); + }); } // When TEST_CLI_RUNTIME=node, also build the npm package so tests @@ -92,3 +112,5 @@ if (process.env["TEST_CLI_RUNTIME"] === "node") { } console.log("npm package built — tests will use Node runtime."); } + +} diff --git a/cli/test/specific_items.test.ts b/cli/test/specific_items_unit.test.ts similarity index 100% rename from cli/test/specific_items.test.ts rename to cli/test/specific_items_unit.test.ts diff --git a/cli/test/tar_creation.test.ts b/cli/test/tar_creation_unit.test.ts similarity index 100% rename from cli/test/tar_creation.test.ts rename to cli/test/tar_creation_unit.test.ts diff --git a/cli/test/test_backend.ts b/cli/test/test_backend.ts index 34ef1f240f..25943e2b98 100644 --- a/cli/test/test_backend.ts +++ b/cli/test/test_backend.ts @@ -590,11 +590,16 @@ function registerCleanup() { cleanupRegistered = true; process.on("exit", () => { if (globalBackend) { - // Synchronous kill — can't await in exit handler - try { - (globalBackend as any).backend?.process?.kill(); - } catch { - // Best effort + // Synchronous kill — can't await in exit handler. + // Kill the direct child (cargo); any orphaned windmill child processes + // will be cleaned up by cleanupStaleTestResources() on next startup. + const pid = (globalBackend as any).backend?.process?.pid; + if (pid) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Best effort — process may already be dead + } } } }); diff --git a/cli/test/wmill_lock.test.ts b/cli/test/wmill_lock_unit.test.ts similarity index 100% rename from cli/test/wmill_lock.test.ts rename to cli/test/wmill_lock_unit.test.ts diff --git a/cli/test/workspace_conflicts.test.ts b/cli/test/workspace_conflicts_unit.test.ts similarity index 100% rename from cli/test/workspace_conflicts.test.ts rename to cli/test/workspace_conflicts_unit.test.ts From 99b0ebd67701c161407221a773f6ffb19bf1fa40 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 16:14:47 +0000 Subject: [PATCH 077/153] use fallback_service instead of nest_service for MCP router (#8566) Co-authored-by: Claude Opus 4.5 --- backend/windmill-api/src/mcp/core.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 7699853559..e23f44ad35 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -546,7 +546,7 @@ pub async fn setup_mcp_server( let service = StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config); - let router = Router::new().nest_service("/", service); + let router = Router::new().fallback_service(service); Ok((router, cancellation_token)) } From bc7007bb4265e1f1375c1f0678b74325882a4e92 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 16:22:35 +0000 Subject: [PATCH 078/153] fix: include importer_kind in dependency debounce key to prevent cross-kind collisions (#8567) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-dep-map/src/trigger_dependents.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/windmill-dep-map/src/trigger_dependents.rs b/backend/windmill-dep-map/src/trigger_dependents.rs index f17d3863ac..1254e41ed1 100644 --- a/backend/windmill-dep-map/src/trigger_dependents.rs +++ b/backend/windmill-dep-map/src/trigger_dependents.rs @@ -61,7 +61,7 @@ pub async fn trigger_dependents_to_recompute_dependencies( ); let mut debouncing_settings = DebouncingSettings { - debounce_key: Some(format!("{w_id}:{importer_path}:dependency")), + debounce_key: Some(format!("{w_id}:{importer_path}:{importer_kind}:dependency")), debounce_delay_s: Some(5), ..Default::default() }; From b592996eee98ddb664f1b007b95a2096d5d4e3a6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 17:52:53 +0000 Subject: [PATCH 079/153] feat: add schedule support to CLI branch-specific items (#8570) Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/core/conf.ts | 4 ++++ cli/src/core/specific_items.ts | 26 +++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index e72aa14414..4a4aec7f25 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -57,6 +57,7 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + schedules?: string[]; folders?: string[]; settings?: boolean; }; @@ -70,6 +71,7 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + schedules?: string[]; folders?: string[]; settings?: boolean; }; @@ -83,6 +85,7 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + schedules?: string[]; folders?: string[]; settings?: boolean; }; @@ -96,6 +99,7 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + schedules?: string[]; folders?: string[]; settings?: boolean; }; diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts index cfd806d9c6..dca403e905 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -8,6 +8,7 @@ export interface SpecificItemsConfig { variables?: string[]; resources?: string[]; triggers?: string[]; + schedules?: string[]; folders?: string[]; settings?: boolean; } @@ -17,6 +18,7 @@ function getBranchSpecificTypes() { return { variable: '.variable.yaml', resource: '.resource.yaml', + schedule: '.schedule.yaml', // Generate trigger patterns from the list ...Object.fromEntries( TRIGGER_TYPES.map(t => [`${t}_trigger`, `.${t}_trigger.yaml`]) @@ -31,6 +33,13 @@ function isTriggerFile(path: string): boolean { return TRIGGER_TYPES.some(type => path.endsWith(`.${type}_trigger.yaml`)); } +/** + * Check if a path is a schedule file + */ +function isScheduleFile(path: string): boolean { + return path.endsWith('.schedule.yaml'); +} + /** * Extract the file type suffix from a path */ @@ -53,7 +62,7 @@ function getFileTypeSuffix(path: string): string | null { * Build regex pattern for all supported yaml file types */ function buildYamlTypePattern(): string { - const basicTypes = ['variable', 'resource']; + const basicTypes = ['variable', 'resource', 'schedule']; const triggerTypes = TRIGGER_TYPES.map(t => `${t}_trigger`); return `((${basicTypes.join('|')})|(${triggerTypes.join('|')}))`; } @@ -100,6 +109,9 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver if (commonItems?.triggers) { merged.triggers = [...commonItems.triggers]; } + if (commonItems?.schedules) { + merged.schedules = [...commonItems.schedules]; + } if (commonItems?.folders) { merged.folders = [...commonItems.folders]; } @@ -117,6 +129,9 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver if (branchItems?.triggers) { merged.triggers = [...(merged.triggers || []), ...branchItems.triggers]; } + if (branchItems?.schedules) { + merged.schedules = [...(merged.schedules || []), ...branchItems.schedules]; + } if (branchItems?.folders) { merged.folders = [...(merged.folders || []), ...branchItems.folders]; } @@ -157,6 +172,10 @@ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsC return specificItems.triggers !== undefined; } + if (isScheduleFile(path)) { + return specificItems.schedules !== undefined; + } + if (path.endsWith('/folder.meta.yaml')) { return specificItems.folders !== undefined; } @@ -194,6 +213,11 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig return specificItems.triggers ? matchesPatterns(path, specificItems.triggers) : false; } + // Check for schedule files + if (isScheduleFile(path)) { + return specificItems.schedules ? matchesPatterns(path, specificItems.schedules) : false; + } + // Check for folder meta files if (path.endsWith('/folder.meta.yaml')) { if (specificItems.folders) { From 63a3573951d1f724cc63728ed973d039a5468072 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 27 Mar 2026 18:57:57 +0100 Subject: [PATCH 080/153] fix: multi-script dedicated workers race on shared job_dir (#8551) (#8569) * [ee] fix: update ee-repo-ref for dedicated worker job_dir fix Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] fix: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc This commit updates the EE repository reference after PR #490 was merged in windmill-ee-private. Previous ee-repo-ref: d958cd3b8a9a17b5f3cb6cb411c8ebba0c380fdd New ee-repo-ref: 5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) 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 3915abda7e..d0328f83f3 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -61ae055ea31481f1899953e9d5f65566b8c707b1 +5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc From 7a14d38d4a25e2238c73e2f00def5b24fc384e85 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 18:12:52 +0000 Subject: [PATCH 081/153] use layer instead of route_layer for MCP router to prevent axum 0.8 panic (#8572) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-api/src/lib.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 450713e614..e714689df0 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -474,14 +474,17 @@ pub async fn run_server( let (mcp_router, mcp_cancellation_token) = setup_mcp_server(db.clone(), user_db, _base_internal_url.clone()).await?; // Workspace-scoped MCP router + // Use `layer` instead of `route_layer` because the MCP router only has + // a fallback_service (no explicit routes), and axum 0.8 panics on + // route_layer with no routes. let workspaced_mcp_router = mcp_router .clone() - .route_layer(from_extractor::()) + .layer(from_extractor::()) .layer(axum::middleware::from_fn(add_www_authenticate_header)) .layer(axum::middleware::from_fn(extract_and_store_workspace_id)); // Gateway MCP router — resolves workspace from token let gateway_mcp_router = mcp_router - .route_layer(from_extractor::()) + .layer(from_extractor::()) .layer(axum::middleware::from_fn( add_www_authenticate_header_gateway, )) From 5e5da4f7ef909387b28f6470b97bb235baffd9ad Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 18:28:59 +0000 Subject: [PATCH 082/153] test: add OTEL coverage tests (#8558) * test: add OTEL coverage tests Add 38 unit tests covering OpenTelemetry infrastructure: - OtelSettings serde (empty, partial, full, roundtrip, skip_serializing) - OtelTracingProxySettings serde (defaults, languages, dedup, rejection) - ScriptLang rename cases - LogCounter initialization and CountingLayer event counting - Targets filter suppression of windmill:job_log - get_otel_context_envs traceparent format verification - Worker OtelTracingProxySettings (HashSet variant) Companion EE PR adds tests for span_cx_from_job_id, metric functions, proto conversion, SpanBuilder, and tracing proxy handler. Co-Authored-By: Claude Opus 4.6 (1M context) * test: add E2E OTEL tests with in-memory exporters Add integration tests that verify metrics and spans flow correctly through the OpenTelemetry pipeline using in-memory exporters: Metrics (1 comprehensive test): - All 20 metric names registered correctly - Counter values (push/delete/pull/zombie/execution/failed/started) - Gauge values with attributes (queue count by tag, worker busy, db pool, health) - Histogram values (execution duration, pull duration) - Health status phase encoding (healthy=1, degraded=0, unhealthy=0) Spans (6 tests): - Root job span created with "full_job" name and Ok status - Error status with "Job failed" description on failure - trace_id derived from job UUID - span_id derived from job UUID low bits - Child jobs (with parent_job) produce no span - Attribute values (job_id, workspace_id, script_path) match job data Also: - Add testing feature to opentelemetry_sdk for InMemoryMetricExporter - Update otel_oss.rs for SdkTracer type rename in 0.30 - Add opentelemetry/opentelemetry_sdk to dev-dependencies Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove unit tests in favor of E2E OTEL tests The E2E integration tests in backend/tests/otel.rs cover the same ground more thoroughly with in-memory exporters. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 2 + backend/Cargo.toml | 4 +- backend/tests/otel.rs | 504 ++++++++++++++++++++ backend/windmill-common/src/otel_oss.rs | 2 +- backend/windmill-common/src/tracing_init.rs | 1 + backend/windmill-worker/src/worker.rs | 1 + 6 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 backend/tests/otel.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 11c743a643..a476bded1f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15828,6 +15828,8 @@ dependencies = [ "git-version", "lazy_static", "once_cell", + "opentelemetry 0.30.0", + "opentelemetry_sdk 0.30.0", "prometheus", "rand 0.9.0", "rdkafka", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b8e4d3d593..31d5665cd7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -260,6 +260,8 @@ windmill-dep-map.workspace = true windmill-test-utils.workspace = true windmill-worker-volumes.workspace = true windmill-types.workspace = true +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } windmill-trigger.workspace = true windmill-trigger-websocket.workspace = true windmill-trigger-postgres.workspace = true @@ -568,7 +570,7 @@ async-stream = "^0" opentelemetry = "0.30.0" tracing-opentelemetry = "0.31.0" -opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] } +opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio", "testing"] } opentelemetry-otlp = { version = "0.30.0", features = ["grpc-tonic", "tls"] } opentelemetry-appender-tracing = "0.30.0" opentelemetry-semantic-conventions = { version = "0.30.0", features = ["semconv_experimental"] } diff --git a/backend/tests/otel.rs b/backend/tests/otel.rs new file mode 100644 index 0000000000..dd56cbf425 --- /dev/null +++ b/backend/tests/otel.rs @@ -0,0 +1,504 @@ +//! E2E tests for OpenTelemetry integration. +//! +//! Verify that metrics are recorded with correct names/values/attributes and +//! spans are created with correct trace IDs, attributes, and status codes. +//! +//! Run with: cargo test --features enterprise,private,otel --test otel -- --test-threads=1 + +#![cfg(all(feature = "otel", feature = "enterprise"))] + +use std::sync::{atomic::Ordering, Arc}; + +use opentelemetry::global; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::{ + metrics::{InMemoryMetricExporter, PeriodicReader, SdkMeterProvider}, + trace::{InMemorySpanExporter, SdkTracerProvider, SimpleSpanProcessor}, +}; +use windmill_common::otel_ee::*; +use windmill_common::{OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED}; + +// ── Global test infrastructure ────────────────────────────────────────── + +struct OtelTestState { + metric_exporter: InMemoryMetricExporter, + span_exporter: InMemorySpanExporter, + meter_provider: SdkMeterProvider, +} + +static STATE: tokio::sync::OnceCell> = tokio::sync::OnceCell::const_new(); + +async fn ensure_setup() -> Arc { + STATE + .get_or_init(|| async { + // Metrics: InMemoryMetricExporter + PeriodicReader (needs async tokio context) + let metric_exporter = InMemoryMetricExporter::default(); + let reader = PeriodicReader::builder(metric_exporter.clone()).build(); + let meter_provider = SdkMeterProvider::builder().with_reader(reader).build(); + global::set_meter_provider(meter_provider.clone()); + OTEL_METRICS_ENABLED.store(true, Ordering::SeqCst); + + // Tracing: InMemorySpanExporter + SimpleSpanProcessor + let span_exporter = InMemorySpanExporter::default(); + let tracer_provider = SdkTracerProvider::builder() + .with_span_processor(SimpleSpanProcessor::new(span_exporter.clone())) + .build(); + let tracer = tracer_provider.tracer("windmill"); + *TRACER.write().unwrap() = Some(tracer); + OTEL_TRACING_ENABLED.store(true, Ordering::SeqCst); + + Arc::new(OtelTestState { metric_exporter, span_exporter, meter_provider }) + }) + .await + .clone() +} + +// ── Metric helper: flush + collect ────────────────────────────────────── + +fn flush_and_get_metrics( + state: &OtelTestState, +) -> Vec { + state.meter_provider.force_flush().expect("flush failed"); + state + .metric_exporter + .get_finished_metrics() + .expect("get_finished_metrics failed") +} + +fn find_metric<'a>( + all: &'a [opentelemetry_sdk::metrics::data::ResourceMetrics], + name: &str, +) -> Option<&'a opentelemetry_sdk::metrics::data::Metric> { + all.iter() + .flat_map(|rm| rm.scope_metrics()) + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == name) +} + +fn metric_names(all: &[opentelemetry_sdk::metrics::data::ResourceMetrics]) -> Vec { + all.iter() + .flat_map(|rm| rm.scope_metrics()) + .flat_map(|sm| sm.metrics()) + .map(|m| m.name().to_string()) + .collect() +} + +// ── Counter value helpers ─────────────────────────────────────────────── + +fn sum_u64_value(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option { + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + match metric.data() { + AggregatedMetrics::U64(MetricData::Sum(sum)) => { + Some(sum.data_points().map(|dp| dp.value()).sum()) + } + _ => None, + } +} + +fn gauge_i64_values( + metric: &opentelemetry_sdk::metrics::data::Metric, +) -> Vec<(Vec, i64)> { + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + match metric.data() { + AggregatedMetrics::I64(MetricData::Gauge(gauge)) => gauge + .data_points() + .map(|dp| (dp.attributes().cloned().collect(), dp.value())) + .collect(), + _ => panic!("expected I64 Gauge metric"), + } +} + +fn gauge_f64_value(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option { + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + match metric.data() { + AggregatedMetrics::F64(MetricData::Gauge(gauge)) => { + gauge.data_points().next().map(|dp| dp.value()) + } + _ => None, + } +} + +fn histogram_f64_count(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option { + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + match metric.data() { + AggregatedMetrics::F64(MetricData::Histogram(hist)) => { + Some(hist.data_points().map(|dp| dp.count()).sum()) + } + _ => None, + } +} + +fn histogram_f64_sum(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option { + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + match metric.data() { + AggregatedMetrics::F64(MetricData::Histogram(hist)) => { + Some(hist.data_points().map(|dp| dp.sum()).sum()) + } + _ => None, + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// METRICS E2E TEST +// +// All metric assertions live in one test function because the PeriodicReader's +// background task is tied to the tokio runtime that created it. Separate +// #[tokio::test] functions each get their own runtime, and the reader becomes +// disconnected after the first test's runtime is dropped. +// ═══════════════════════════════════════════════════════════════════════ + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_all_metrics_e2e() { + let state = ensure_setup().await; + + // ── Counters ──────────────────────────────────────────────────── + + otel_incr_queue_push_count(); + otel_incr_queue_push_count(); + otel_incr_queue_push_count(); + otel_incr_queue_delete_count(); + otel_incr_queue_pull_count(); + otel_incr_zombie_restart_count(7); + otel_incr_zombie_delete_count(3); + otel_incr_worker_execution_count("bun"); + otel_incr_worker_execution_count("bun"); + otel_incr_worker_execution_failed("go"); + otel_incr_worker_started(); + + // ── Gauges ────────────────────────────────────────────────────── + + otel_set_queue_count("python3", 42); + otel_set_queue_running_count("deno", 5); + otel_set_worker_busy("worker-test-1", 1); + otel_set_db_pool(5, 10, 20); + otel_set_health_db_latency(2.5); + otel_set_worker_uptime("w-uptime", 3600.0); + otel_set_health_status_phase("healthy"); + otel_set_health_db_unresponsive(true); + + // ── Histograms ────────────────────────────────────────────────── + + otel_record_worker_execution_duration("python3", 1.5); + otel_record_worker_execution_duration("python3", 2.5); + otel_record_worker_pull_duration("w1", true, 0.05); + otel_record_worker_pull_duration("w1", false, 0.01); + + // ── Flush and collect ─────────────────────────────────────────── + + let metrics = flush_and_get_metrics(&state); + let names = metric_names(&metrics); + + // ── Verify all 20 metric names are present ────────────────────── + + let expected = [ + "windmill.queue.push_count", + "windmill.queue.delete_count", + "windmill.queue.pull_count", + "windmill.queue.zombie_restart_count", + "windmill.queue.zombie_delete_count", + "windmill.queue.count", + "windmill.queue.running_count", + "windmill.worker.execution_count", + "windmill.worker.execution_duration", + "windmill.worker.busy", + "windmill.worker.pull_duration", + "windmill.worker.execution_failed", + "windmill.db.pool.active", + "windmill.db.pool.idle", + "windmill.db.pool.max", + "windmill.health.db_latency", + "windmill.worker.started", + "windmill.worker.uptime", + "windmill.health.status", + "windmill.health.db_unresponsive", + ]; + for name in expected { + assert!( + names.iter().any(|n| n == name), + "metric '{}' not found in {:?}", + name, + names + ); + } + + // ── Counter values ────────────────────────────────────────────── + + let m = find_metric(&metrics, "windmill.queue.push_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 3, "push_count should be >= 3"); + + let m = find_metric(&metrics, "windmill.queue.delete_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 1); + + let m = find_metric(&metrics, "windmill.queue.pull_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 1); + + let m = find_metric(&metrics, "windmill.queue.zombie_restart_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 7); + + let m = find_metric(&metrics, "windmill.queue.zombie_delete_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 3); + + let m = find_metric(&metrics, "windmill.worker.execution_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 2); + + let m = find_metric(&metrics, "windmill.worker.execution_failed").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 1); + + let m = find_metric(&metrics, "windmill.worker.started").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 1); + + // ── Gauge values ──────────────────────────────────────────────── + + let m = find_metric(&metrics, "windmill.queue.count").unwrap(); + let values = gauge_i64_values(m); + let dp = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "tag" && kv.value.as_str() == "python3") + }) + .expect("queue.count data point with tag=python3 not found"); + assert_eq!(dp.1, 42); + + let m = find_metric(&metrics, "windmill.queue.running_count").unwrap(); + let values = gauge_i64_values(m); + let dp = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "tag" && kv.value.as_str() == "deno") + }) + .expect("running_count data point with tag=deno not found"); + assert_eq!(dp.1, 5); + + let m = find_metric(&metrics, "windmill.worker.busy").unwrap(); + let values = gauge_i64_values(m); + let dp = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "worker" && kv.value.as_str() == "worker-test-1") + }) + .expect("worker.busy data point with worker=worker-test-1 not found"); + assert_eq!(dp.1, 1); + + let m = find_metric(&metrics, "windmill.db.pool.active").unwrap(); + assert_eq!(gauge_i64_values(m)[0].1, 5); + let m = find_metric(&metrics, "windmill.db.pool.idle").unwrap(); + assert_eq!(gauge_i64_values(m)[0].1, 10); + let m = find_metric(&metrics, "windmill.db.pool.max").unwrap(); + assert_eq!(gauge_i64_values(m)[0].1, 20); + + let m = find_metric(&metrics, "windmill.health.db_latency").unwrap(); + assert!((gauge_f64_value(m).unwrap() - 2.5).abs() < f64::EPSILON); + + let m = find_metric(&metrics, "windmill.worker.uptime").unwrap(); + assert!((gauge_f64_value(m).unwrap() - 3600.0).abs() < f64::EPSILON); + + let m = find_metric(&metrics, "windmill.health.db_unresponsive").unwrap(); + assert_eq!(gauge_i64_values(m)[0].1, 1); + + // ── Health status phase (all 3 phases) ────────────────────────── + + let m = find_metric(&metrics, "windmill.health.status").unwrap(); + let values = gauge_i64_values(m); + let healthy = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "phase" && kv.value.as_str() == "healthy") + }) + .expect("phase=healthy"); + let degraded = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "phase" && kv.value.as_str() == "degraded") + }) + .expect("phase=degraded"); + let unhealthy = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "phase" && kv.value.as_str() == "unhealthy") + }) + .expect("phase=unhealthy"); + assert_eq!(healthy.1, 1); + assert_eq!(degraded.1, 0); + assert_eq!(unhealthy.1, 0); + + // ── Histogram values ──────────────────────────────────────────── + + let m = find_metric(&metrics, "windmill.worker.execution_duration").unwrap(); + assert!(histogram_f64_count(m).unwrap() >= 2); + assert!(histogram_f64_sum(m).unwrap() >= 4.0); + + let m = find_metric(&metrics, "windmill.worker.pull_duration").unwrap(); + assert!(histogram_f64_count(m).unwrap() >= 2); +} + +// ═══════════════════════════════════════════════════════════════════════ +// SPAN E2E TESTS +// ═══════════════════════════════════════════════════════════════════════ + +fn make_test_job(id: uuid::Uuid, parent: Option) -> windmill_queue::MiniPulledJob { + use windmill_types::jobs::JobKind; + let mut job = windmill_queue::MiniPulledJob::new_inline( + "test-workspace".to_string(), + None, + "test-user".to_string(), + "u/test-user".to_string(), + "test@example.com".to_string(), + Some("f/test/script".to_string()), + JobKind::Script, + None, + "deno".to_string(), + None, + ); + job.id = id; + job.parent_job = parent; + job.started_at = Some(chrono::Utc::now()); + job +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_span_created_on_success() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job_id = uuid::Uuid::new_v4(); + let job = make_test_job(job_id, None); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + assert_eq!(span.status, opentelemetry::trace::Status::Ok,); + + // Verify attributes + let attrs: Vec<_> = span.attributes.iter().map(|kv| kv.key.as_str()).collect(); + assert!(attrs.contains(&"job_id"), "missing job_id attribute"); + assert!( + attrs.contains(&"workspace_id"), + "missing workspace_id attribute" + ); + assert!( + attrs.contains(&"script_path"), + "missing script_path attribute" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_span_error_on_failure() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job_id = uuid::Uuid::new_v4(); + let job = make_test_job(job_id, None); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, false); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + match &span.status { + opentelemetry::trace::Status::Error { description } => { + assert_eq!(description.as_ref(), "Job failed"); + } + other => panic!("expected Error status, got {:?}", other), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_trace_id_matches_uuid() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let job = make_test_job(job_id, None); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + let expected_trace_id = + opentelemetry::trace::TraceId::from_bytes(job_id.as_u128().to_be_bytes()); + assert_eq!(span.span_context.trace_id(), expected_trace_id); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_span_id_matches_uuid() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let job = make_test_job(job_id, None); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + let expected_span_id = + opentelemetry::trace::SpanId::from_bytes(job_id.as_u64_pair().1.to_be_bytes()); + assert_eq!(span.span_context.span_id(), expected_span_id); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_child_job_produces_no_span() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let parent_id = uuid::Uuid::new_v4(); + let job_id = uuid::Uuid::new_v4(); + let job = make_test_job(job_id, Some(parent_id)); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let found = spans.iter().any(|s| s.name == "full_job"); + assert!(!found, "child job should not produce a span"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_span_attributes_values() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job_id = uuid::Uuid::new_v4(); + let job = make_test_job(job_id, None); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + let get_attr = |key: &str| -> String { + span.attributes + .iter() + .find(|kv| kv.key.as_str() == key) + .map(|kv| kv.value.as_str().to_string()) + .unwrap_or_default() + }; + + assert_eq!(get_attr("job_id"), job_id.to_string()); + assert_eq!(get_attr("workspace_id"), "test-workspace"); + assert_eq!(get_attr("script_path"), "f/test/script"); +} diff --git a/backend/windmill-common/src/otel_oss.rs b/backend/windmill-common/src/otel_oss.rs index 3710464607..27c7101dc0 100644 --- a/backend/windmill-common/src/otel_oss.rs +++ b/backend/windmill-common/src/otel_oss.rs @@ -59,7 +59,7 @@ pub(crate) fn init_otlp_tracer( _mode: &Mode, _hostname: &str, _env: &str, -) -> Option { +) -> Option { None } diff --git a/backend/windmill-common/src/tracing_init.rs b/backend/windmill-common/src/tracing_init.rs index 3b009a36eb..1a2ca33518 100644 --- a/backend/windmill-common/src/tracing_init.rs +++ b/backend/windmill-common/src/tracing_init.rs @@ -273,3 +273,4 @@ where } } } + diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index b75403bef7..8aeb3a9e5e 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -5412,3 +5412,4 @@ pub fn get_worker_internal_server_inline_utils( )), } } + From dc75b73edcbee0adc27cd8fef348b0f7c357bb80 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 27 Mar 2026 14:41:10 -0400 Subject: [PATCH 083/153] improve logging for github app operations (#8568) * improve logging for github app operations * ee ref * chore: update ee-repo-ref to 0b9e92f9e089293c6d523b77ed2c11edbc7a99c0 This commit updates the EE repository reference after PR #489 was merged in windmill-ee-private. Previous ee-repo-ref: b259642e7f36b83a991034d5b28ae616f94ee5fc New ee-repo-ref: 0b9e92f9e089293c6d523b77ed2c11edbc7a99c0 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- backend/windmill-common/src/workspaces.rs | 2 +- frontend/src/lib/hubPaths.json | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 444df60a0c..1d105e24db 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -149,7 +149,7 @@ pub enum ObjectType { WorkspaceDependencies, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28180/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28183/sync-script-to-git-repo-windmill"; #[derive(Serialize, Deserialize, Debug)] pub struct GitRepositorySettings { diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index ccdc663d18..86b8d27ef5 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -25,10 +25,12 @@ "deprecated_gitSync_23": "hub/28160/sync-script-to-git-repo-windmill", "deprecated_gitSync_24": "hub/28176/sync-script-to-git-repo-windmill", "deprecated_gitSync_latest": "hub/28180/sync-script-to-git-repo-windmill", - "gitSyncTest": "hub/28177/git-repo-test-read-write-windmill", + "deprecated_gitSync_25": "hub/28183/sync-script-to-git-repo-windmill", + "gitSyncTest": "hub/28184/git-repo-test-read-write-windmill", "gitInitRepo_0": "hub/28134/git-sync%3A-init-repository-windmill", "gitInitRepo_1": "hub/28158/git-sync%3A-init-repository-windmill", - "gitInitRepo": "hub/28174/git-sync%3A-init-repository-windmill", + "gitInitRepo_2": "hub/28174/git-sync%3A-init-repository-windmill", + "gitInitRepo": "hub/28181/git-sync%3A-init-repository-windmill", "slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack", "slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack", "slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack", @@ -45,5 +47,6 @@ "appReport": "hub/28076/app-report", "cloneRepoToS3forGitRepoViewer_0": "hub/19825/clone_repo_and_upload_to_instance_storage", "cloneRepoToS3forGitRepoViewer_1": "hub/19827/clone_repo_and_upload_to_instance_storage", - "cloneRepoToS3forGitRepoViewer": "hub/28175/clone_repo_and_upload_to_instance_storage" + "cloneRepoToS3forGitRepoViewer_2": "hub/28175/clone_repo_and_upload_to_instance_storage", + "cloneRepoToS3forGitRepoViewer": "hub/28182/clone_repo_and_upload_to_instance_storage" } From 3959fe82974f5f0383e94fd83a5d78fe4212d56a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 19:23:03 +0000 Subject: [PATCH 084/153] feat: add workspace-level service accounts (#8560) * feat: add workspace-level service accounts (EE) Co-Authored-By: Claude Opus 4.6 (1M context) * sqlx * sqlx * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...05768ed9a679ba908ab16497a9bd55578ba1.json} | 12 +- ...685848af38a3461257a9734c43cbd7bd905cb.json | 35 ++ ...b81763d8650c1316bb0b20816f1a5d61a678c.json | 8 +- ...ea032b00fc9bd7a6db22f530f67eb9730fa3b.json | 8 +- ...8c4aed45e6b8f409bb33c2681f92265922040.json | 24 + ...4046f7586e36b7bd6a4679d70c359d4aacfcf.json | 20 + ...de553031e581b1bb173413a9a3e3eb0817b43.json | 16 + ...3bc9c48f71a44827ba0d01ac5588dc31082a2.json | 8 +- ...a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json | 20 + ...02a9bd039d16f7dfb11e22d16ff9090456853.json | 16 + backend/ee-repo-ref.txt | 2 +- .../20260326200000_service_accounts.down.sql | 1 + .../20260326200000_service_accounts.up.sql | 1 + .../20260327000000_email_varchar_255.down.sql | 2 + .../20260327000000_email_varchar_255.up.sql | 2 + backend/windmill-api-auth/src/auth.rs | 18 +- backend/windmill-api-users/src/lib.rs | 3 + backend/windmill-api-users/src/users.rs | 77 ++- backend/windmill-api-users/src/users_oss.rs | 28 ++ .../windmill-api-workspaces/src/workspaces.rs | 15 + .../src/workspaces_oss.rs | 16 +- backend/windmill-api/openapi.yaml | 83 ++++ frontend/src/lib/components/AddUser.svelte | 114 +++-- .../settings/WorkspaceUserSettings.svelte | 80 +++- .../components/sidebar/OperatorMenu.svelte | 444 +++++++++--------- frontend/src/lib/stores.ts | 1 + .../src/routes/(root)/(logged)/+layout.svelte | 34 +- 27 files changed, 814 insertions(+), 274 deletions(-) rename backend/.sqlx/{query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json => query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json} (75%) create mode 100644 backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json create mode 100644 backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json create mode 100644 backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json create mode 100644 backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json create mode 100644 backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json create mode 100644 backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json create mode 100644 backend/migrations/20260326200000_service_accounts.down.sql create mode 100644 backend/migrations/20260326200000_service_accounts.up.sql create mode 100644 backend/migrations/20260327000000_email_varchar_255.down.sql create mode 100644 backend/migrations/20260327000000_email_varchar_255.up.sql create mode 100644 backend/windmill-api-users/src/users_oss.rs diff --git a/backend/.sqlx/query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json b/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json similarity index 75% rename from backend/.sqlx/query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json rename to backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json index 6e1b36a97c..b4a9f19f45 100644 --- a/backend/.sqlx/query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json +++ b/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT usr.*, password.super_admin, password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2\n ", + "query": "SELECT usr.*, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2\n ", "describe": { "columns": [ { @@ -50,11 +50,16 @@ }, { "ordinal": 9, - "name": "super_admin", + "name": "is_service_account", "type_info": "Bool" }, { "ordinal": 10, + "name": "super_admin!", + "type_info": "Bool" + }, + { + "ordinal": 11, "name": "name", "type_info": "Varchar" } @@ -76,8 +81,9 @@ true, true, false, + null, true ] }, - "hash": "6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b" + "hash": "1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1" } diff --git a/backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json b/backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json new file mode 100644 index 0000000000..2086242e9c --- /dev/null +++ b/backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, is_service_account, disabled FROM usr WHERE username = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "is_service_account", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "disabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb" +} diff --git a/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json b/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json index 09775dcc3a..79625b6baf 100644 --- a/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json +++ b/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json @@ -47,6 +47,11 @@ "ordinal": 8, "name": "added_via", "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "is_service_account", + "type_info": "Bool" } ], "parameters": { @@ -63,7 +68,8 @@ false, false, true, - true + true, + false ] }, "hash": "5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c" diff --git a/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json b/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json index 3a635ab004..ed09f2833f 100644 --- a/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json +++ b/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json @@ -47,6 +47,11 @@ "ordinal": 8, "name": "added_via", "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "is_service_account", + "type_info": "Bool" } ], "parameters": { @@ -64,7 +69,8 @@ false, false, true, - true + true, + false ] }, "hash": "60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b" diff --git a/backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json b/backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json new file mode 100644 index 0000000000..cd1d6810cd --- /dev/null +++ b/backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND (username = $2 OR email = $3))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040" +} diff --git a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json b/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json new file mode 100644 index 0000000000..5661c59faf --- /dev/null +++ b/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM usr WHERE is_service_account = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf" +} diff --git a/backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json b/backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json new file mode 100644 index 0000000000..2c00759a63 --- /dev/null +++ b/backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43" +} diff --git a/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json b/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json index cdeb30f672..7be961c050 100644 --- a/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json +++ b/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json @@ -47,6 +47,11 @@ "ordinal": 8, "name": "added_via", "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "is_service_account", + "type_info": "Bool" } ], "parameters": { @@ -63,7 +68,8 @@ false, false, true, - true + true, + false ] }, "hash": "e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2" diff --git a/backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json b/backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json new file mode 100644 index 0000000000..24d9cd9517 --- /dev/null +++ b/backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, owner)\n VALUES ($1, $2, $3, $4, $5, $6, false, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b" +} diff --git a/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json b/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json new file mode 100644 index 0000000000..117a8bdc1b --- /dev/null +++ b/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, is_service_account)\n VALUES ($1, $2, $3, false, true, true)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index d0328f83f3..4751795cd9 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc +208da6989ef606e4068663246903acbcaa90a9dc \ No newline at end of file diff --git a/backend/migrations/20260326200000_service_accounts.down.sql b/backend/migrations/20260326200000_service_accounts.down.sql new file mode 100644 index 0000000000..88a50692ab --- /dev/null +++ b/backend/migrations/20260326200000_service_accounts.down.sql @@ -0,0 +1 @@ +ALTER TABLE usr DROP COLUMN is_service_account; diff --git a/backend/migrations/20260326200000_service_accounts.up.sql b/backend/migrations/20260326200000_service_accounts.up.sql new file mode 100644 index 0000000000..b9e96d0baa --- /dev/null +++ b/backend/migrations/20260326200000_service_accounts.up.sql @@ -0,0 +1 @@ +ALTER TABLE usr ADD COLUMN IF NOT EXISTS is_service_account BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/migrations/20260327000000_email_varchar_255.down.sql b/backend/migrations/20260327000000_email_varchar_255.down.sql new file mode 100644 index 0000000000..b5ff4d22c2 --- /dev/null +++ b/backend/migrations/20260327000000_email_varchar_255.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE magic_link ALTER COLUMN email TYPE VARCHAR(50); +ALTER TABLE schedule ALTER COLUMN email TYPE VARCHAR(50); diff --git a/backend/migrations/20260327000000_email_varchar_255.up.sql b/backend/migrations/20260327000000_email_varchar_255.up.sql new file mode 100644 index 0000000000..95adb957b3 --- /dev/null +++ b/backend/migrations/20260327000000_email_varchar_255.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE magic_link ALTER COLUMN email TYPE VARCHAR(255); +ALTER TABLE schedule ALTER COLUMN email TYPE VARCHAR(255); diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index f10ae321b9..b5b04cb3e8 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -225,7 +225,15 @@ impl AuthCache { t_hash, w_id.as_ref(), ) - .map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label)) + .map(|x| { + ( + x.owner, + x.email, + x.super_admin, + x.scopes, + x.label, + ) + }) .fetch_optional(&self.db) .await .ok() @@ -234,7 +242,13 @@ impl AuthCache { if let Some(user) = user_o { let authed_o = { match user { - (Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => { + ( + Some(owner), + Some(email), + super_admin, + _, + label, + ) if w_id.is_some() => { let username_override = username_override_from_label(label); if let Some((prefix, name)) = owner.split_once('/') { if prefix == "u" { diff --git a/backend/windmill-api-users/src/lib.rs b/backend/windmill-api-users/src/lib.rs index 913bd46b82..ee5369e616 100644 --- a/backend/windmill-api-users/src/lib.rs +++ b/backend/windmill-api-users/src/lib.rs @@ -1 +1,4 @@ pub mod users; +#[cfg(feature = "private")] +pub mod users_ee; +mod users_oss; diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index b3b32859c7..4bb61183ce 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -58,7 +58,7 @@ use windmill_common::{ use windmill_common::{BASE_URL, HUB_BASE_URL}; use windmill_git_sync::handle_deployment_metadata; -const COOKIE_PATH: &str = "/"; +pub const COOKIE_PATH: &str = "/"; pub fn workspaced_service() -> Router { Router::new() @@ -75,6 +75,11 @@ pub fn workspaced_service() -> Router { .route("/whoami", get(whoami)) .route("/leave", post(leave_workspace)) .route("/username_to_email/{username}", get(username_to_email)) + .route( + "/impersonate_service_account", + post(impersonate_service_account), + ) + .route("/exit_impersonation", post(exit_impersonation)) } pub fn global_service() -> Router { @@ -135,6 +140,7 @@ pub struct User { pub role: Option, #[serde(skip_serializing_if = "Option::is_none")] pub added_via: Option, + pub is_service_account: bool, } #[derive(Serialize)] @@ -176,6 +182,7 @@ pub struct UserInfo { pub folders: Vec, pub folders_owners: Vec, pub name: Option, + pub is_service_account: bool, } #[derive(FromRow, Serialize)] @@ -620,8 +627,9 @@ async fn is_valid_logout_redirect(rd: &str) -> bool { async fn whoami( Extension(db): Extension, Path(w_id): Path, - ApiAuthed { username, email, is_admin, groups, folders, .. }: ApiAuthed, + authed: ApiAuthed, ) -> JsonResult { + let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed; let user = get_user(&w_id, &username, &db).await?; if let Some(user) = user { Ok(Json(user)) @@ -648,6 +656,7 @@ async fn whoami( .into_iter() .filter_map(|x| if x.2 { Some(x.0) } else { None }) .collect(), + is_service_account: false, })) } } @@ -663,11 +672,11 @@ async fn global_whoami( email = $1", email ) - .fetch_one(&db) + .fetch_optional(&db) .await - .map_err(|e| Error::internal_err(format!("fetching global identity: {e:#}"))); + .map_err(|e| Error::internal_err(format!("fetching global identity: {e:#}")))?; - if let Ok(user) = user { + if let Some(user) = user { Ok(Json(user)) } else if std::env::var("SUPERADMIN_SECRET").ok() == Some(token) { Ok(Json(GlobalUserInfo { @@ -685,7 +694,21 @@ async fn global_whoami( disabled: false, })) } else { - Err(user.unwrap_err()) + // Service accounts don't have a password row + Ok(Json(GlobalUserInfo { + email: email.clone(), + login_type: Some("service_account".to_string()), + super_admin: false, + devops: false, + verified: true, + name: None, + company: None, + username: None, + operator_only: Some(true), + first_time_user: false, + role_source: "service_account".to_string(), + disabled: false, + })) } } @@ -736,12 +759,13 @@ pub struct User2 { pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub added_via: Option, + pub is_service_account: bool, } async fn get_user(w_id: &str, username: &str, db: &DB) -> Result> { let user = sqlx::query_as!( User2, - "SELECT usr.*, password.super_admin, password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2 + "SELECT usr.*, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2 ", username, w_id @@ -782,6 +806,7 @@ async fn get_user(w_id: &str, username: &str, db: &DB) -> Result, + authed: ApiAuthed, + cookies: Cookies, + Tokened { token: current_token }: Tokened, + Path(w_id): Path, + Json(req): Json, +) -> Result<(StatusCode, String)> { + crate::users_oss::impersonate_service_account(db, authed, cookies, current_token, w_id, req) + .await +} + +#[derive(Deserialize)] +struct ExitImpersonationRequest { + token: String, +} + +async fn exit_impersonation( + cookies: Cookies, + Json(req): Json, +) -> Result { + let mut cookie = tower_cookies::Cookie::new(COOKIE_NAME, req.token); + cookie.set_secure(IS_SECURE.read().await.clone()); + cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax)); + cookie.set_http_only(true); + cookie.set_path(COOKIE_PATH); + if COOKIE_DOMAIN.is_some() { + cookie.set_domain(COOKIE_DOMAIN.clone().unwrap()); + } + cookies.add(cookie); + Ok("exited impersonation".to_string()) +} + #[derive(Deserialize)] struct ListTokenQuery { exclude_ephemeral: Option, diff --git a/backend/windmill-api-users/src/users_oss.rs b/backend/windmill-api-users/src/users_oss.rs new file mode 100644 index 0000000000..a42cce8405 --- /dev/null +++ b/backend/windmill-api-users/src/users_oss.rs @@ -0,0 +1,28 @@ +#[cfg(feature = "private")] +#[allow(unused)] +pub use crate::users_ee::*; + +#[cfg(not(feature = "private"))] +use crate::users::ImpersonateServiceAccountRequest; +#[cfg(not(feature = "private"))] +use http::StatusCode; +#[cfg(not(feature = "private"))] +use tower_cookies::Cookies; +#[cfg(not(feature = "private"))] +use windmill_api_auth::ApiAuthed; +#[cfg(not(feature = "private"))] +use windmill_common::DB; + +#[cfg(not(feature = "private"))] +pub async fn impersonate_service_account( + _db: DB, + _authed: ApiAuthed, + _cookies: Cookies, + _current_token: String, + _w_id: String, + _req: ImpersonateServiceAccountRequest, +) -> windmill_common::error::Result<(StatusCode, String)> { + Err(windmill_common::error::Error::BadRequest( + "Service accounts require Windmill Enterprise Edition".to_string(), + )) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 08b9d4d3d6..db09f16393 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -75,6 +75,7 @@ pub fn workspaced_service() -> Router { .route("/archive", post(archive_workspace)) .route("/invite_user", post(invite_user)) .route("/add_user", post(add_user)) + .route("/create_service_account", post(create_service_account)) .route("/delete_invite", post(delete_invite)) .route("/rebuild_dependency_map", post(rebuild_dependency_map)) .route("/get_dependency_map", get(get_dependency_map)) @@ -4232,6 +4233,20 @@ If you do not have an account on {}, login with SSO or ask an admin to create an )) } +#[derive(Deserialize)] +pub struct NewServiceAccount { + pub username: String, +} + +async fn create_service_account( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(nu): Json, +) -> Result<(StatusCode, String)> { + crate::workspaces_oss::create_service_account(authed, db, w_id, nu).await +} + async fn delete_invite( ApiAuthed { username, is_admin, .. }: ApiAuthed, Extension(db): Extension, diff --git a/backend/windmill-api-workspaces/src/workspaces_oss.rs b/backend/windmill-api-workspaces/src/workspaces_oss.rs index da46622c26..872061e554 100644 --- a/backend/windmill-api-workspaces/src/workspaces_oss.rs +++ b/backend/windmill-api-workspaces/src/workspaces_oss.rs @@ -3,7 +3,9 @@ pub use crate::workspaces_ee::*; #[cfg(not(feature = "private"))] -use crate::workspaces::EditAutoInvite; +use crate::workspaces::{EditAutoInvite, NewServiceAccount}; +#[cfg(not(feature = "private"))] +use http::StatusCode; #[cfg(not(feature = "private"))] use windmill_api_auth::ApiAuthed; #[cfg(not(feature = "private"))] @@ -20,3 +22,15 @@ pub async fn edit_auto_invite( "Not implemented on OSS".to_string(), )) } + +#[cfg(not(feature = "private"))] +pub async fn create_service_account( + _authed: ApiAuthed, + _db: DB, + _w_id: String, + _nu: NewServiceAccount, +) -> windmill_common::error::Result<(StatusCode, String)> { + Err(windmill_common::error::Error::BadRequest( + "Service accounts require Windmill Enterprise Edition".to_string(), + )) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index aa3505eec7..082298ab03 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2125,6 +2125,87 @@ paths: schema: type: string + /w/{workspace}/workspaces/create_service_account: + post: + summary: create a service account + operationId: createServiceAccount + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + required: + - username + responses: + "201": + description: service account created + content: + text/plain: + schema: + type: string + + /w/{workspace}/users/impersonate_service_account: + post: + summary: impersonate a service account + operationId: impersonateServiceAccount + tags: + - user + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + required: + - username + responses: + "201": + description: impersonation token + content: + text/plain: + schema: + type: string + + /w/{workspace}/users/exit_impersonation: + post: + summary: exit service account impersonation + operationId: exitImpersonation + tags: + - user + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + token: + type: string + required: + - token + responses: + "200": + description: exited impersonation + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/delete_invite: post: summary: delete user invite @@ -20123,6 +20204,8 @@ components: nullable: true allOf: - $ref: "#/components/schemas/UserSource" + is_service_account: + type: boolean required: - email - username diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index 42f0e0430a..c537740480 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -1,6 +1,6 @@ @@ -80,15 +91,27 @@ {/snippet} {#snippet content()} -
    +
    Add a new user - Email - + {#if isServiceAccount} + Username + + {:else} + Email + - {#if !automateUsernameCreation} - Username - + {#if !automateUsernameCreation} + Username + + {/if} {/if} Role @@ -112,6 +135,13 @@ tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace." {item} /> + {/snippet} diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index 03b57f6d9a..d7221248ee 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -14,9 +14,15 @@ import Tooltip from '$lib/components/Tooltip.svelte' import type { CancelablePromise, User, UserUsage } from '$lib/gen' import { UserService, WorkspaceService, GroupService, type WorkspaceInvite } from '$lib/gen' - import { userStore, workspaceStore, superadmin, globalEmailInvite } from '$lib/stores' + import { + userStore, + workspaceStore, + superadmin, + globalEmailInvite, + enterpriseLicense + } from '$lib/stores' import { sendUserToast } from '$lib/toast' - import { Loader2, Mails, Search, Plus, UserMinus, X } from 'lucide-svelte' + import { Loader2, Mails, Search, Plus, UserMinus, X, Bot, LogIn } from 'lucide-svelte' import Select from '$lib/components/select/Select.svelte' import SearchItems from '../SearchItems.svelte' import Cell from '../table/Cell.svelte' @@ -45,6 +51,8 @@ let selectedNewInstanceGroup: string | undefined = $state(undefined) let selectedNewRole: string | undefined = $state('developer') + // Service account creation + // Available groups for dropdowns - filter out already configured groups let availableGroupItems = $derived( instanceGroups @@ -488,12 +496,14 @@ {#snippet children({ item })} {/if} - {truncate(email, 20)} - {truncate(username, 30)} + + {#if user.is_service_account} + + + {email} + + {:else} + {email} + {/if} + + {username} {#if hasNonManualUsers}
    @@ -796,14 +825,21 @@ {/if} {#if usage?.[email] != undefined}{usage?.[email]}{:else}{#if usage != undefined}{usage[email] ?? 0}{:else}{/if}
    - {#if added_via?.source === 'instance_group'} + {#if user.is_service_account} +
    + + Operator + + Service accounts are always operators. +
    + {:else if added_via?.source === 'instance_group'}
    {is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'} @@ -840,6 +876,7 @@ {#snippet children({ item })}
    + {#if user.is_service_account && $userStore?.is_admin} + + {/if} {#snippet removeUserButton(disabled: boolean)}
    diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 22251f6aa6..7084b9a951 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -28,6 +28,7 @@ export interface UserExt { pgroups: string[] folders: string[] folders_owners: string[] + is_service_account?: boolean } export interface UserWorkspace { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index b384b11a1e..43372d8337 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -676,7 +676,7 @@
    {/if} {:else} -
    +
    {/if} @@ -775,6 +775,38 @@
    {/if}
    + {#if $userStore?.is_service_account} +
    + + Viewing workspace on behalf of {$userStore.username} + (impersonated by {$userStore.impersonating_email}) + + +
    + {/if} Date: Fri, 27 Mar 2026 20:27:56 +0100 Subject: [PATCH 085/153] fix(cli): preserve inline script files during flow generate-locks (#8561) * fix(cli): preserve inline script files during flow generate-locks Three bugs caused `wmill flow generate-locks` to destroy inline script content and rename files: 1. YAML parser stripped unquoted `!inline` tags (treated as YAML tag, not string prefix), leaving just the filename as script content. Fix: register custom YAML tags for `!inline` and `!inline_fileset`. 2. Inline script files were renamed based on step summaries because `extractInlineScriptsForFlows` was called with empty mapping `{}`. Fix: call existing `extractCurrentMapping()` before replacement and pass the mapping to preserve original filenames. 3. Lock file paths were derived from the assigner instead of the mapped content path, causing inconsistent naming. Fix: derive lock base path from mapped content path when available. Co-Authored-By: Claude Opus 4.6 (1M context) * test(cli): add unit tests for !inline YAML tag and mapping preservation - YAML tag tests: unquoted/quoted !inline parsing, !inline_fileset, nested structures, round-trip stability - Mapping tests: path preservation with mapping, fallthrough without mapping, lock path derivation from mapped content path, mixed mapped/unmapped modules, dotted path handling Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): correct yaml parse type cast and inline prefix check Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): harden lock path for extensionless files and merge customTags Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/commands/flow/flow_metadata.ts | 19 ++++- cli/src/utils/yaml.ts | 39 ++++++++-- ..._scripts_failure_preprocessor_unit.test.ts | 78 +++++++++++++++++++ cli/test/yaml_inline_tag.test.ts | 58 ++++++++++++++ .../src/inline-scripts/extractor.ts | 13 +++- 5 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 cli/test/yaml_inline_tag.test.ts diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index 1f5d86b8ea..9805391d05 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -18,7 +18,7 @@ import { filterWorkspaceDependenciesForScripts, } 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 { extractInlineScripts as extractInlineScriptsForFlows, extractCurrentMapping } 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"; @@ -188,6 +188,17 @@ 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"); + + // Capture existing module-ID-to-file-path mapping before replaceInlineScripts + // overwrites the !inline references with actual file content. This preserves + // the original filenames when re-extracting inline scripts after lock generation. + const currentMapping = extractCurrentMapping( + flowValue.value.modules, + {}, + flowValue.value.failure_module, + flowValue.value.preprocessor_module, + ); + // In tree mode, use the tree's staleness info (which includes transitive dependency changes) // to determine which scripts need relocking, instead of only content-changed ones. const locksToRemove = (tree && !legacyBehaviour) @@ -228,16 +239,16 @@ export async function generateFlowLockInternal( }); const inlineScripts = extractInlineScriptsForFlows( flowValue.value.modules, - {}, + currentMapping, SEP, opts.defaultTs, lockAssigner ); if (flowValue.value.failure_module) { - inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], {}, SEP, opts.defaultTs, lockAssigner)); + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner)); } if (flowValue.value.preprocessor_module) { - inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], {}, SEP, opts.defaultTs, lockAssigner)); + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner)); } inlineScripts.forEach((s) => { writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content); diff --git a/cli/src/utils/yaml.ts b/cli/src/utils/yaml.ts index 9ad247c1fd..52ec682067 100644 --- a/cli/src/utils/yaml.ts +++ b/cli/src/utils/yaml.ts @@ -1,9 +1,35 @@ -import { parse as yamlParse, type ParseOptions } from "yaml"; +import { parse as yamlParse } from "yaml"; +import type { ParseOptions, DocumentOptions, SchemaOptions, ToJSOptions, ScalarTag } from "yaml"; import { readFile } from "node:fs/promises"; -export async function yamlParseFile(path: string, options: ParseOptions = {}) { +// Custom YAML tags that resolve `!inline value` and `!inline_fileset value` +// back to their string-prefix form ("!inline value"). +// Without these, the yaml parser strips the tag and returns just the scalar, +// breaking the string-prefix-based !inline detection used throughout the CLI. +const inlineTag: ScalarTag = { + tag: "!inline", + resolve(value: string) { + return "!inline " + value; + }, +}; + +const inlineFilesetTag: ScalarTag = { + tag: "!inline_fileset", + resolve(value: string) { + return "!inline_fileset " + value; + }, +}; + +const WINDMILL_CUSTOM_TAGS: ScalarTag[] = [inlineTag, inlineFilesetTag]; + +type YamlParseOptions = ParseOptions & DocumentOptions & SchemaOptions & ToJSOptions; + +export async function yamlParseFile(path: string, options: YamlParseOptions = {}) { try { - return yamlParse(await readFile(path, "utf-8"), options); + return yamlParse(await readFile(path, "utf-8"), { + ...options, + customTags: [...WINDMILL_CUSTOM_TAGS, ...((options.customTags as ScalarTag[] | undefined) ?? [])], + }); } catch (e) { throw new Error(`Error parsing yaml ${path}`, { cause: e }); } @@ -12,10 +38,13 @@ export async function yamlParseFile(path: string, options: ParseOptions = {}) { export function yamlParseContent( path: string, content: string, - options: ParseOptions = {}, + options: YamlParseOptions = {}, ) { try { - return yamlParse(content, options); + return yamlParse(content, { + ...options, + customTags: [...WINDMILL_CUSTOM_TAGS, ...((options.customTags as ScalarTag[] | undefined) ?? [])], + }); } catch (e) { throw new Error(`Error parsing yaml ${path}`, { cause: e }); } diff --git a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts index 1a9150a257..78af37a02e 100644 --- a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts +++ b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts @@ -496,3 +496,81 @@ describe("extractCurrentMapping for failure_module / preprocessor_module", () => expect(mapping["failure"]).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// extractInlineScripts with mapping — path preservation +// --------------------------------------------------------------------------- + +describe("extractInlineScripts with mapping preserves file paths", () => { + test("uses mapped path instead of assigner-generated path", () => { + const mod = makeRawscriptModule("a", "console.log('hi')", "bun"); + mod.summary = "Get Users Data"; + + const mapping = { a: "get_users.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "bun"); + + const contentScript = scripts.find((s) => !s.is_lock); + expect(contentScript!.path).toBe("get_users.ts"); + // Module content should reference the mapped path + expect(mod.value.content).toBe("!inline get_users.ts"); + }); + + test("falls through to assigner when module ID not in mapping", () => { + const mod = makeRawscriptModule("a", "console.log('hi')", "bun"); + mod.summary = "Get Users Data"; + + const mapping = { other_id: "other.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "bun"); + + const contentScript = scripts.find((s) => !s.is_lock); + // Should use assigner path based on summary, not mapped + expect(contentScript!.path).toContain("get_users_data"); + }); + + test("mapped modules and unmapped modules coexist", () => { + const modA = makeRawscriptModule("a", "code_a", "bun"); + modA.summary = "Step A"; + const modB = makeRawscriptModule("b", "code_b", "bun"); + modB.summary = "Step B"; + + const mapping = { a: "my_custom_name.ts" }; // only a is mapped + const scripts = extractInlineScripts([modA, modB], mapping, "/", "bun"); + + const paths = scripts.filter((s) => !s.is_lock).map((s) => s.path); + expect(paths[0]).toBe("my_custom_name.ts"); + expect(paths[1]).toContain("step_b"); // assigner-generated from summary + }); + + test("lock path is derived from mapped content path", () => { + const mod = makeRawscriptModule("a", "code", "bun", "lock-content"); + mod.summary = "Get Users Data"; + + const mapping = { a: "get_users.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "bun"); + + const lockScript = scripts.find((s) => s.is_lock); + expect(lockScript!.path).toBe("get_users.lock"); + expect((mod.value as any).lock).toBe("!inline get_users.lock"); + }); + + test("lock path uses assigner basePath when no mapping", () => { + const mod = makeRawscriptModule("a", "code", "bun", "lock-content"); + mod.summary = "Get Users Data"; + + const scripts = extractInlineScripts([mod], {}, "/", "bun"); + + const lockScript = scripts.find((s) => s.is_lock); + expect(lockScript!.path).toContain("get_users_data"); + expect(lockScript!.path).toEndWith(".lock"); + }); + + test("lock path handles dotted content paths correctly", () => { + const mod = makeRawscriptModule("a", "code", "bun", "lock-content"); + + const mapping = { a: "my.inline_script.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "bun"); + + const lockScript = scripts.find((s) => s.is_lock); + expect(lockScript!.path).toBe("my.inline_script.lock"); + }); +}); diff --git a/cli/test/yaml_inline_tag.test.ts b/cli/test/yaml_inline_tag.test.ts new file mode 100644 index 0000000000..79638c7b50 --- /dev/null +++ b/cli/test/yaml_inline_tag.test.ts @@ -0,0 +1,58 @@ +/** + * Unit tests for custom !inline and !inline_fileset YAML tag handling. + * These tests require no backend — they test YAML parsing logic. + */ + +import { expect, test, describe } from "bun:test"; +import { yamlParseContent } from "../src/utils/yaml.ts"; +import { stringify as yamlStringify } from "yaml"; + +describe("YAML !inline tag resolution", () => { + test("unquoted !inline resolves to string with prefix", () => { + const result = yamlParseContent("test.yaml", "content: !inline get_users.ts"); + expect(result.content).toBe("!inline get_users.ts"); + }); + + test("quoted !inline is preserved as-is", () => { + const result = yamlParseContent("test.yaml", 'content: "!inline get_users.ts"'); + expect(result.content).toBe("!inline get_users.ts"); + }); + + test("unquoted and quoted produce identical results", () => { + const unquoted = yamlParseContent("test.yaml", "content: !inline script.ts"); + const quoted = yamlParseContent("test.yaml", 'content: "!inline script.ts"'); + expect(unquoted.content).toBe(quoted.content); + }); + + test("unquoted !inline_fileset resolves to string with prefix", () => { + const result = yamlParseContent("test.yaml", "value: !inline_fileset my_resource.fileset"); + expect(result.value).toBe("!inline_fileset my_resource.fileset"); + }); + + test("works within nested flow.yaml structure", () => { + const yaml = ` +value: + modules: + - id: a + value: + type: rawscript + content: !inline get_users.ts + language: bun + - id: b + value: + type: rawscript + content: !inline send_mail.ts + language: bun`; + const result = yamlParseContent("flow.yaml", yaml); + expect(result.value.modules[0].value.content).toBe("!inline get_users.ts"); + expect(result.value.modules[1].value.content).toBe("!inline send_mail.ts"); + }); + + test("round-trip: parse unquoted → stringify → parse preserves value", () => { + const yaml = "content: !inline my_script.ts"; + const parsed = yamlParseContent("test.yaml", yaml); + const serialized = yamlStringify(parsed); + const reparsed = yamlParseContent("test.yaml", serialized); + expect(reparsed.content).toBe("!inline my_script.ts"); + }); +}); diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index 0ad1bb8302..e472372a99 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -23,14 +23,21 @@ function extractRawscriptInline( assigner: PathAssigner ): InlineScript[] { const [basePath, ext] = assigner.assignPath(summary ?? id, rawscript.language); - const path = mapping[id] ?? basePath + ext; + const mappedPath = mapping[id]; + const path = mappedPath ?? basePath + ext; const language = rawscript.language; const content = rawscript.content; const r = [{ path: path, content: content, language, is_lock: false}]; rawscript.content = "!inline " + path.replaceAll(separator, "/"); const lock = rawscript.lock; if (lock && lock != "") { - const lockPath = basePath + "lock"; + // Derive lock path base from the mapped content path when available, + // so lock files are named consistently with their content files. + const dotIdx = mappedPath ? mappedPath.lastIndexOf('.') : -1; + const lockBasePath = mappedPath + ? (dotIdx > 0 ? mappedPath.substring(0, dotIdx + 1) : mappedPath + '.') + : basePath; + const lockPath = lockBasePath + "lock"; rawscript.lock = "!inline " + lockPath.replaceAll(separator, "/"); r.push({ path: lockPath, content: lock, language, is_lock: true}); } @@ -191,7 +198,7 @@ export function extractCurrentMapping( } else if (m.value.type === "aiagent") { (m.value.tools ?? []).forEach((tool) => { const toolValue = tool.value; - if (!toolValue || toolValue.tool_type !== 'flowmodule' || toolValue.type !== 'rawscript' || !toolValue.content || !toolValue.content.startsWith("!inline")) { + if (!toolValue || toolValue.tool_type !== 'flowmodule' || toolValue.type !== 'rawscript' || !toolValue.content || !toolValue.content.startsWith("!inline ")) { return; } mapping[tool.id] = toolValue.content.trim().split(" ")[1]; From 248188aaa2ea065fc34523dec2a62e1adb1af8ac Mon Sep 17 00:00:00 2001 From: Pyra <92104930+pyranota@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:28:48 +0100 Subject: [PATCH 086/153] nit: add `workflow_dispatch` to cli tests (#8479) --- .github/workflows/cli-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 9c87a249a3..237a5ff555 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -1,6 +1,7 @@ name: CLI Tests on: + workflow_dispatch: push: branches: [main] paths: From 80cf26bb6106720de3ced640678332fe732c2326 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 19:39:55 +0000 Subject: [PATCH 087/153] nit npm checks --- frontend/src/lib/components/AddUser.svelte | 4 ++-- .../lib/components/settings/WorkspaceUserSettings.svelte | 1 + frontend/src/lib/stores.ts | 1 + frontend/src/lib/user.ts | 6 +++++- frontend/src/routes/(root)/(logged)/+layout.svelte | 1 + 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index c537740480..3a76bb96e1 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -10,7 +10,6 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import { UserPlus } from 'lucide-svelte' - import Tooltip from './Tooltip.svelte' const dispatch = createEventDispatcher() @@ -80,7 +79,8 @@ dispatch('new') } - let selected: 'operator' | 'developer' | 'admin' | 'service_account' = $state('developer') + type UserRole = 'operator' | 'developer' | 'admin' | 'service_account' + let selected: UserRole = $state('developer' as UserRole) let isServiceAccount = $derived(selected === 'service_account') diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index d7221248ee..8b245c7c70 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -942,6 +942,7 @@ }) if (oldToken) { sessionStorage.setItem('pre_impersonation_token', oldToken) + sessionStorage.setItem('pre_impersonation_email', $userStore?.email ?? '') } window.location.href = '/' } catch (e) { diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 7084b9a951..6687801a29 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -29,6 +29,7 @@ export interface UserExt { folders: string[] folders_owners: string[] is_service_account?: boolean + impersonating_email?: string } export interface UserWorkspace { diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index 9c2965e655..aaca893636 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -11,9 +11,13 @@ export async function getUserExt(workspace: string): Promise `g/${x}`) } + if (ext.is_service_account && sessionStorage.getItem('pre_impersonation_token')) { + ext.impersonating_email = sessionStorage.getItem('pre_impersonation_email') ?? undefined + } + return ext } diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 43372d8337..2616e87b90 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -799,6 +799,7 @@ console.error('Failed to exit impersonation', e) } sessionStorage.removeItem('pre_impersonation_token') + sessionStorage.removeItem('pre_impersonation_email') } window.location.href = '/workspace_settings?tab=users' }} From 522da50c974bee18702daa031b3b70e741e0cea9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 20:03:54 +0000 Subject: [PATCH 088/153] chore(main): release 1.667.0 (#8549) * chore(main): release 1.667.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 29 +++ backend/Cargo.lock | 170 +++++++++--------- 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, 130 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc5b9ad23..34ffeebd65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [1.667.0](https://github.com/windmill-labs/windmill/compare/v1.666.0...v1.667.0) (2026-03-27) + + +### Features + +* add schedule support to CLI branch-specific items ([#8570](https://github.com/windmill-labs/windmill/issues/8570)) ([b592996](https://github.com/windmill-labs/windmill/commit/b592996eee98ddb664f1b007b95a2096d5d4e3a6)) +* add workspace-level service accounts ([#8560](https://github.com/windmill-labs/windmill/issues/8560)) ([3959fe8](https://github.com/windmill-labs/windmill/commit/3959fe82974f5f0383e94fd83a5d78fe4212d56a)) +* **cli:** generate commented wmill.yaml and add config reference command ([#8546](https://github.com/windmill-labs/windmill/issues/8546)) ([d06b426](https://github.com/windmill-labs/windmill/commit/d06b42613f73c4a7b31c990be22b0c97efab2666)) +* DB-coordinated graceful restart staggering for settings changes ([#8555](https://github.com/windmill-labs/windmill/issues/8555)) ([2f32675](https://github.com/windmill-labs/windmill/commit/2f326758013dd1f1e6ae732e5784a32f1fb6e4bd)) +* improve-replay-ui ([#8250](https://github.com/windmill-labs/windmill/issues/8250)) ([c0aafee](https://github.com/windmill-labs/windmill/commit/c0aafee9a9923d5dc2fa3b99da4378e923933a06)) +* support multiple folder selection in MCP scope selector ([#8557](https://github.com/windmill-labs/windmill/issues/8557)) ([ad19ac9](https://github.com/windmill-labs/windmill/commit/ad19ac9b37b04591c921f93f180bdda961af6cef)) + + +### Bug Fixes + +* **cli:** preserve inline script files during flow generate-locks ([#8561](https://github.com/windmill-labs/windmill/issues/8561)) ([a8b651d](https://github.com/windmill-labs/windmill/commit/a8b651da9ff86766119e14c0b61652be8a7b453a)) +* emit 0 for OTEL queue metrics when tag queue is empty ([#8559](https://github.com/windmill-labs/windmill/issues/8559)) ([79cc4a9](https://github.com/windmill-labs/windmill/commit/79cc4a92d88486c999799826bd0c9663767103f5)) +* handle inline script deletion in sync push + flow new nonDottedPaths ([#8553](https://github.com/windmill-labs/windmill/issues/8553)) ([943fe9c](https://github.com/windmill-labs/windmill/commit/943fe9c6cc9b046e24007e45b5c37afc4804256a)) +* include importer_kind in dependency debounce key to prevent cross-kind collisions ([#8567](https://github.com/windmill-labs/windmill/issues/8567)) ([bc7007b](https://github.com/windmill-labs/windmill/commit/bc7007bb4265e1f1375c1f0678b74325882a4e92)) +* multi-script dedicated workers race on shared job_dir ([#8551](https://github.com/windmill-labs/windmill/issues/8551)) ([#8569](https://github.com/windmill-labs/windmill/issues/8569)) ([63a3573](https://github.com/windmill-labs/windmill/commit/63a3573951d1f724cc63728ed973d039a5468072)) +* preserve notes on nodes inside collapsed groups ([#8552](https://github.com/windmill-labs/windmill/issues/8552)) ([0fb1153](https://github.com/windmill-labs/windmill/commit/0fb115304afc49812420e9ce24e5048502621059)) +* sanitize flow step summaries for filesystem-safe names ([#8554](https://github.com/windmill-labs/windmill/issues/8554)) ([e15bfbf](https://github.com/windmill-labs/windmill/commit/e15bfbf91ee1517432a6861ebb48e129485006aa)) +* use admin db pool in get_copilot_settings_state ([#8564](https://github.com/windmill-labs/windmill/issues/8564)) ([70f3ee5](https://github.com/windmill-labs/windmill/commit/70f3ee5ed4470e9993be822874f2b38e83a96611)) + + +### Performance Improvements + +* enable bun bundle caching for WAC v2 scripts ([#8556](https://github.com/windmill-labs/windmill/issues/8556)) ([ab868e9](https://github.com/windmill-labs/windmill/commit/ab868e9ebceadaa55e54770d9d59dc5524da13ff)) + ## [1.666.0](https://github.com/windmill-labs/windmill/compare/v1.665.0...v1.666.0) (2026-03-26) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a476bded1f..5d1fff935e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2180,9 +2180,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -8596,9 +8596,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -11564,9 +11564,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.40.0" +version = "1.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" dependencies = [ "arrayvec", "borsh", @@ -11577,6 +11577,7 @@ dependencies = [ "rkyv", "serde", "serde_json", + "wasm-bindgen", ] [[package]] @@ -12582,9 +12583,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd-json" @@ -14197,7 +14198,7 @@ dependencies = [ "bytes", "io-uring", "libc", - "mio 1.1.1", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -15427,6 +15428,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -15811,7 +15813,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-nats", @@ -15889,7 +15891,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -15902,7 +15904,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "argon2", @@ -16043,7 +16045,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16066,7 +16068,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16079,7 +16081,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16105,7 +16107,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.666.0" +version = "1.667.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16115,7 +16117,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16132,7 +16134,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "base64 0.22.1", @@ -16155,7 +16157,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16178,7 +16180,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16194,7 +16196,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16214,7 +16216,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16234,7 +16236,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16248,7 +16250,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-nats", @@ -16278,7 +16280,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16303,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "flate2", @@ -16321,7 +16323,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16343,7 +16345,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16363,7 +16365,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16393,7 +16395,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16420,7 +16422,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.666.0" +version = "1.667.0" dependencies = [ "lazy_static", "serde", @@ -16432,7 +16434,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.666.0" +version = "1.667.0" dependencies = [ "argon2", "axum 0.8.4", @@ -16456,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16470,7 +16472,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16502,7 +16504,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.666.0" +version = "1.667.0" dependencies = [ "chrono", "lazy_static", @@ -16516,7 +16518,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16535,7 +16537,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.666.0" +version = "1.667.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16636,7 +16638,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.666.0" +version = "1.667.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16655,7 +16657,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.666.0" +version = "1.667.0" dependencies = [ "regex", "serde", @@ -16670,7 +16672,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16694,7 +16696,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "futures", @@ -16711,7 +16713,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.666.0" +version = "1.667.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16727,7 +16729,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -16748,7 +16750,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -16779,7 +16781,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-oauth2", @@ -16803,7 +16805,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-stream", @@ -16837,7 +16839,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "futures", @@ -16855,7 +16857,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.666.0" +version = "1.667.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16864,7 +16866,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "lazy_static", @@ -16876,7 +16878,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "serde_json", @@ -16888,7 +16890,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "gosyn", @@ -16900,7 +16902,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "lazy_static", @@ -16912,7 +16914,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "serde_json", @@ -16924,7 +16926,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "nu-parser", @@ -16935,7 +16937,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16946,7 +16948,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16958,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16969,7 +16971,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-recursion", @@ -16991,7 +16993,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "lazy_static", @@ -17005,7 +17007,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -17022,7 +17024,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "lazy_static", @@ -17035,7 +17037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "serde", @@ -17047,7 +17049,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "lazy_static", @@ -17065,7 +17067,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17081,7 +17083,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17097,7 +17099,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "serde", @@ -17108,7 +17110,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-recursion", @@ -17145,7 +17147,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "const_format", @@ -17183,7 +17185,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.666.0" +version = "1.667.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17194,7 +17196,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-recursion", @@ -17223,7 +17225,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17247,7 +17249,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17280,7 +17282,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17300,7 +17302,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17334,7 +17336,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17369,7 +17371,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17392,7 +17394,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17416,7 +17418,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-nats", @@ -17440,7 +17442,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17475,7 +17477,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17503,7 +17505,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17526,7 +17528,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17545,7 +17547,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-once-cell", @@ -17653,7 +17655,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.666.0" +version = "1.667.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 31d5665cd7..1463710d36 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.666.0" +version = "1.667.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.666.0" +version = "1.667.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 082298ab03..911bf25933 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.666.0 + version: 1.667.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index ac0e20f064..725a232d19 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.666.0"; +export const VERSION = "v1.667.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 5126bc6c92..2cb9f1d6d4 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -70,7 +70,7 @@ export { workspaceAdd, }; -export const VERSION = "1.666.0"; +export const VERSION = "1.667.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 1c3a993774..e061fac83c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.666.0", + "version": "1.667.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.666.0", + "version": "1.667.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 14e7119c7a..e372c2f4dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.666.0", + "version": "1.667.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index d2a3a0360a..41d6d9f9c8 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.666.0" +wmill = ">=1.667.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index b6065e0a91..c58f491774 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.666.0 + version: 1.667.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index f68c5d40bc..55bb44d0bd 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.666.0' + ModuleVersion = '1.667.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index fdcefd7e6f..b10bf8fd48 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.666.0" +version = "1.667.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 b75bb4c78c..58feb1c707 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.666.0", + "version": "1.667.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 21a7e3055e..1e30116112 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.666.0", + "version": "1.667.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 8a00e52c64..11172c375e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.666.0 +1.667.0 From 56253c04cb679c58d00750da699a6cb62ed52aca Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 27 Mar 2026 17:50:29 -0400 Subject: [PATCH 089/153] feat: IAM RDS auth for PostgreSQL worker resources (#8573) * feat: add IAM RDS auth support for PostgreSQL worker resources Co-Authored-By: Claude Opus 4.6 * refactor: use Config builder for IAM RDS connections Co-Authored-By: Claude Opus 4.6 * fix: address PR review feedback for IAM RDS auth Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to ebea6ef1e5bfcfc3f0151da9687dac6c61bbfab6 This commit updates the EE repository reference after PR #493 was merged in windmill-ee-private. Previous ee-repo-ref: 1228561a98c5195bb97a81d4a57ce2bb2ecfca79 New ee-repo-ref: ebea6ef1e5bfcfc3f0151da9687dac6c61bbfab6 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-common/src/lib.rs | 73 ++++++++++++++++++++++ backend/windmill-worker/Cargo.toml | 2 +- backend/windmill-worker/src/pg_executor.rs | 26 +++++++- 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4751795cd9..6a22b66a17 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -208da6989ef606e4068663246903acbcaa90a9dc \ No newline at end of file +ebea6ef1e5bfcfc3f0151da9687dac6c61bbfab6 diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index b5ec518315..deb1c38a03 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -406,6 +406,8 @@ pub struct PgDatabase { pub sslmode: Option, pub dbname: String, pub root_certificate_pem: Option, + pub use_iam_auth: Option, + pub region: Option, } // Wrapper enum to hold either Tls or NoTls connection @@ -513,6 +515,75 @@ impl PgDatabase { } } + #[cfg(all(feature = "enterprise", feature = "private"))] + pub async fn connect_with_iam( + &self, + ) -> Result<(tokio_postgres::Client, TokioPgConnection), error::Error> { + use native_tls::TlsConnector; + use postgres_native_tls::MakeTlsConnector; + + // Resolve region: resource field takes priority, then env var + let region = match self.region.as_deref() { + Some(r) => r.to_string(), + None => std::env::var("AWS_REGION").map_err(|_| { + error::Error::BadConfig( + "Region is required for IAM RDS auth. Set 'region' on the resource or AWS_REGION env var".to_string(), + ) + })?, + }; + + let port = self.port.unwrap_or(5432); + let user = self.user.as_deref().unwrap_or("postgres"); + + let token = db_iam_ee::generate_auth_token(®ion, &self.host, port as u64, user) + .await + .map_err(|e| { + error::Error::InternalErr(format!("IAM token generation failed: {e:#}")) + })?; + + // RDS IAM auth requires SSL + let mut connector = TlsConnector::builder(); + if let Some(root_certificate_pem) = &self.root_certificate_pem { + if !root_certificate_pem.is_empty() { + connector.add_root_certificate( + native_tls::Certificate::from_pem(root_certificate_pem.as_bytes()) + .map_err(|e| error::Error::BadConfig(format!("Invalid Certs: {e:#}")))?, + ); + } else { + connector.danger_accept_invalid_certs(true); + connector.danger_accept_invalid_hostnames(true); + } + } else { + tracing::warn!("IAM RDS auth without root certificate: TLS certificate verification is disabled. Consider providing root_certificate_pem for production use."); + connector + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true); + } + + tracing::info!("Creating new IAM RDS connection to {}", &self.host); + + // Use Config builder directly to pass the IAM token as the password. + // This avoids needing to URL-encode the token into a connection string. + let mut config = tokio_postgres::Config::new(); + config + .host(&self.host) + .port(port as u16) + .user(user) + .password(&token) + .dbname(&self.dbname) + .ssl_mode(tokio_postgres::config::SslMode::Require); + + let (client, connection) = tokio::time::timeout( + std::time::Duration::from_secs(20), + config.connect(MakeTlsConnector::new(connector.build().map_err(to_anyhow)?)), + ) + .await + .map_err(to_anyhow)? + .map_err(to_anyhow)?; + + Ok((client, TokioPgConnection::Tls(connection))) + } + pub fn parse_uri(url: &str) -> Result { let parsed_url = url::Url::parse(url) .map_err(|_| Error::BadConfig("Invalid PostgreSQL URL".to_string()))?; @@ -551,6 +622,8 @@ impl PgDatabase { dbname, sslmode, root_certificate_pem: None, + use_iam_auth: None, + region: None, }) } } diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index c1e2a4927a..b65a2489ac 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -10,7 +10,7 @@ path = "src/lib.rs" [features] default = [] -private = ["windmill-worker-volumes/private", "windmill-queue/private"] +private = ["windmill-worker-volumes/private", "windmill-queue/private", "windmill-common/private"] mcp = ["dep:windmill-mcp"] prometheus = ["dep:prometheus", "windmill-common/prometheus"] enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker-volumes/enterprise", "dep:pem", "dep:tokio-util", "dep:opentelemetry-proto", "dep:prost", "dep:hudsucker", "dep:rcgen", "dep:hyper-http-proxy", "dep:hyper-tls", "dep:hyper-util"] diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 5b309a611c..f769e1034d 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -285,7 +285,16 @@ pub async fn do_postgresql( annotations.result_collection }; - let database_string = database.to_uri(); + let use_iam_auth = database.use_iam_auth == Some(true); + + // Include use_iam_auth in cache key to distinguish IAM vs non-IAM connections to the same host. + // The cache key is static (doesn't include the token), which is correct because PostgreSQL + // connections remain valid after initial auth — fresh tokens are generated on cache miss. + let database_string = if use_iam_auth { + format!("{}?iam=true", database.to_uri()) + } else { + database.to_uri() + }; let database_string_clone = database_string.clone(); let mtex; @@ -309,7 +318,20 @@ pub async fn do_postgresql( ); (None, mtex) } else { - let (client, connection) = database.connect().await?; + let (client, connection) = if use_iam_auth { + #[cfg(all(feature = "enterprise", feature = "private"))] + { + database.connect_with_iam().await? + } + #[cfg(not(all(feature = "enterprise", feature = "private")))] + { + return Err(Error::ExecutionErr( + "IAM RDS authentication requires Windmill Enterprise Edition".to_string(), + )); + } + } else { + database.connect().await? + }; let handle = tokio::spawn(async move { if let Err(e) = connection.await { let mut mtex = CONNECTION_CACHE.lock().await; From ce2e6c8c015110d0385e6afecdc8313aabca1364 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 23:49:40 +0000 Subject: [PATCH 090/153] fix: add Authority Key Identifier to MITM proxy leaf certs (#8576) * test: add x509-parser dev-dep for MITM proxy cert tests Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt for ssl-verify-fix branch Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to a90b083660b372bf1da1c18769cbd50936ea8040 This commit updates the EE repository reference after PR #494 was merged in windmill-ee-private. Previous ee-repo-ref: db665a09d5b9a485977d73c22908629e3dda6200 New ee-repo-ref: a90b083660b372bf1da1c18769cbd50936ea8040 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/Cargo.lock | 1 + backend/Cargo.toml | 1 + backend/ee-repo-ref.txt | 2 +- backend/windmill-worker/Cargo.toml | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5d1fff935e..6503da8594 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17650,6 +17650,7 @@ dependencies = [ "windmill-runtime-nativets", "windmill-types", "windmill-worker-volumes", + "x509-parser 0.16.0", "yaml-rust", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1463710d36..08726d5d7d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -417,6 +417,7 @@ time = "^0" serde_urlencoded = "^0" astral-tokio-tar = "^0.5.6" tempfile = "^3" +x509-parser = "^0.16" tokio-util = { version = "=0.7.17", features = ["io"] } json-pointer = "^0" itertools = "^0.14.0" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 6a22b66a17..5f5eb5d758 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ebea6ef1e5bfcfc3f0151da9687dac6c61bbfab6 +a90b083660b372bf1da1c18769cbd50936ea8040 diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index b65a2489ac..8955041435 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -146,6 +146,7 @@ rcgen = { workspace = true, optional = true } [dev-dependencies] tempfile.workspace = true +x509-parser.workspace = true [build-dependencies] libffi-sys = { workspace = true, optional = true } From 95688884cecd5c287b2a37d34fc4ce3a9ae52b5e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 28 Mar 2026 00:09:38 +0000 Subject: [PATCH 091/153] update ee-repo-ref to fix deprecated rand API in CI (#8577) * [ee] fix: update ee-repo-ref to fix deprecated rand API in CI Updates ee-repo-ref.txt to point to a commit that replaces deprecated rand::thread_rng().gen() with rand::rng().random() in the MITM proxy cert generation, fixing the check_ee_full CI failure. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 9316adc693d7f1a668df661e000109bb48b93375 This commit updates the EE repository reference after PR #495 was merged in windmill-ee-private. Previous ee-repo-ref: d311a3c6ecb50c086fb86b1f4fa3f9e62ff40df5 New ee-repo-ref: 9316adc693d7f1a668df661e000109bb48b93375 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) 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 5f5eb5d758..e92e39c3c1 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a90b083660b372bf1da1c18769cbd50936ea8040 +9316adc693d7f1a668df661e000109bb48b93375 From 501a4ff2a94510145952686d24ccc639781beefe Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 28 Mar 2026 08:41:52 +0000 Subject: [PATCH 092/153] fix: Improve CLI developer experience: error handling, sync workflow, JSON output, workspace forks (#8578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): address 28 DX friction points across CLI commands Co-Authored-By: Claude Opus 4.5 * chore(cli): regenerate system prompts after help text updates Co-Authored-By: Claude Opus 4.5 * fix(cli): address PR review feedback Co-Authored-By: Claude Opus 4.5 * fix(cli): update removeType tests to match lenient behavior Co-Authored-By: Claude Opus 4.5 * fix(cli): address CE/EE sync friction and improve JSON output Co-Authored-By: Claude Opus 4.5 * fix(cli): revert instance config masking to avoid breaking push flow Co-Authored-By: Claude Opus 4.5 * fix(cli): mask instance secrets by default with interactive prompt Co-Authored-By: Claude Opus 4.5 * chore(cli): regenerate system prompts Co-Authored-By: Claude Opus 4.5 * fix(cli): use stderr for errors, optimize skipped-files scan, rename --auto to --auto-metadata Co-Authored-By: Claude Opus 4.5 * feat(cli): improve workspace fork lifecycle — delete-fork fallback, list-forks, --workspace override Co-Authored-By: Claude Opus 4.5 * fix(cli): update fork merge instructions to reference all merge methods Co-Authored-By: Claude Opus 4.5 * fix(cli): clarify skipped-files warning comment re DynFSElement traversal Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- cli/bootstrap/flow_bootstrap.ts | 2 - cli/src/commands/app/app.ts | 23 +++- cli/src/commands/dev/dev.ts | 2 +- cli/src/commands/docs/docs.ts | 2 +- cli/src/commands/flow/flow.ts | 33 ++++- cli/src/commands/folder/folder.ts | 1 + .../generate-metadata/generate-metadata.ts | 119 +++++++++++------- cli/src/commands/instance/instance.ts | 39 +++++- .../commands/resource-type/resource-type.ts | 1 + cli/src/commands/resource/resource.ts | 1 + cli/src/commands/schedule/schedule.ts | 3 +- cli/src/commands/script/script.ts | 50 ++++++-- cli/src/commands/sync/sync.ts | 95 +++++++++++--- cli/src/commands/trigger/trigger.ts | 24 +++- cli/src/commands/user/user.ts | 2 +- cli/src/commands/variable/variable.ts | 1 + cli/src/commands/workspace/fork.ts | 85 ++++++++----- cli/src/commands/workspace/workspace.ts | 92 ++++++++++++-- cli/src/core/conf.ts | 11 +- cli/src/core/context.ts | 11 +- cli/src/core/log.ts | 9 +- cli/src/guidance/skills.ts | 13 +- cli/src/main.ts | 17 ++- cli/src/types.ts | 12 +- cli/test/utils_unit.test.ts | 8 +- .../auto-generated/cli/cli-commands.md | 13 +- system_prompts/auto-generated/prompts.ts | 13 +- .../skills/cli-commands/SKILL.md | 13 +- 28 files changed, 528 insertions(+), 167 deletions(-) diff --git a/cli/bootstrap/flow_bootstrap.ts b/cli/bootstrap/flow_bootstrap.ts index 8bae373b17..3a71051505 100644 --- a/cli/bootstrap/flow_bootstrap.ts +++ b/cli/bootstrap/flow_bootstrap.ts @@ -13,7 +13,6 @@ export interface FlowDefinition { properties: { [name: string]: SchemaProperty}, required: string[] } - ws_error_handler_muted: false } export function defaultFlowDefinition(): FlowDefinition { @@ -30,6 +29,5 @@ export function defaultFlowDefinition(): FlowDefinition { properties: {}, required: [] }, - ws_error_handler_muted: false, } } diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index f048aac616..13851e5f8c 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -5,6 +5,7 @@ import { Table } from "@cliffy/table"; import { colors } from "@cliffy/ansi/colors"; import * as log from "../../core/log.ts"; import { sep as SEP } from "node:path"; +import { stat } from "node:fs/promises"; import * as windmillUtils from "@windmill-labs/shared-utils"; import { yamlParseFile } from "../../utils/yaml.ts"; import * as wmill from "../../../gen/services.gen.ts"; @@ -241,8 +242,26 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); - await pushApp(workspace.workspaceId, remotePath, filePath); - log.info(colors.bold.underline.green("App pushed")); + // Detect raw apps by checking for raw_app.yaml or __raw_app/.raw_app suffix + const normalizedPath = filePath.endsWith(SEP) ? filePath.slice(0, -1) : filePath; + const isRawApp = normalizedPath.endsWith("__raw_app") || normalizedPath.endsWith(".raw_app"); + let hasRawAppYaml = false; + if (!isRawApp) { + try { + const rawAppPath = (filePath.endsWith(SEP) ? filePath : filePath + SEP) + "raw_app.yaml"; + await stat(rawAppPath); + hasRawAppYaml = true; + } catch { /* not a raw app */ } + } + + if (isRawApp || hasRawAppYaml) { + const { pushRawApp } = await import("./raw_apps.ts"); + await pushRawApp(workspace.workspaceId, remotePath, filePath); + log.info(colors.bold.underline.green("Raw app pushed")); + } else { + await pushApp(workspace.workspaceId, remotePath, filePath); + log.info(colors.bold.underline.green("App pushed")); + } } const command = new Command() diff --git a/cli/src/commands/dev/dev.ts b/cli/src/commands/dev/dev.ts index 41b6b8fd9c..5352270f15 100644 --- a/cli/src/commands/dev/dev.ts +++ b/cli/src/commands/dev/dev.ts @@ -236,7 +236,7 @@ async function dev(opts: GlobalOptions & SyncOptions) { } const command = new Command() - .description("Launch a dev server that will spawn a webserver with HMR") + .description("Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development.") .option( "--includes ", "Filter paths givena glob pattern or path" diff --git a/cli/src/commands/docs/docs.ts b/cli/src/commands/docs/docs.ts index 288faf335d..d86d4ca367 100644 --- a/cli/src/commands/docs/docs.ts +++ b/cli/src/commands/docs/docs.ts @@ -106,7 +106,7 @@ async function docs( const command = new Command() .name("docs") - .description("Search Windmill documentation. Requires Enterprise Edition.") + .description("Search Windmill documentation.") .arguments("") .option("--json", "Output results as JSON.") .action(docs as any); diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 7e8bd8c28f..2ca309e42b 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -218,6 +218,7 @@ async function push(opts: Options, filePath: string, remotePath: string) { async function list( opts: GlobalOptions & { showArchived?: boolean; includeDraftOnly?: boolean; json?: boolean } ) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -265,6 +266,16 @@ async function get(opts: GlobalOptions & { json?: boolean }, path: string) { console.log(colors.bold("Description:") + " " + (f.description ?? "")); console.log(colors.bold("Edited by:") + " " + (f.edited_by ?? "")); console.log(colors.bold("Edited at:") + " " + (f.edited_at ?? "")); + // API response type doesn't include flow value/modules — cast needed to access them + const modules = (f as any).value?.modules; + if (modules && Array.isArray(modules) && modules.length > 0) { + console.log(colors.bold("Steps:")); + for (const mod of modules) { + const type = mod.value?.type ?? "unknown"; + const detail = mod.value?.language ?? mod.value?.path ?? ""; + console.log(` ${mod.id}: ${type}${detail ? " (" + detail + ")" : ""}`); + } + } } } @@ -275,6 +286,9 @@ async function run( }, path: string ) { + if (opts.silent) { + log.setSilent(true); + } const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -322,7 +336,11 @@ async function run( workspace: workspace.workspaceId, id, }); - log.info(JSON.stringify(jobInfo.result ?? {}, null, 2)); + if (opts.silent) { + console.log(JSON.stringify(jobInfo.result ?? {})); + } else { + log.info(JSON.stringify(jobInfo.result ?? {}, null, 2)); + } } async function preview( @@ -333,6 +351,9 @@ async function preview( } & SyncOptions, flowPath: string ) { + if (opts.silent) { + log.setSilent(true); + } const useLocalPathScripts = !opts.remote; if (useLocalPathScripts) { opts = await mergeConfigWithConfigFile(opts); @@ -341,14 +362,16 @@ async function preview( await requireLogin(opts); const codebases = useLocalPathScripts ? listSyncCodebases(opts) : []; - // Normalize path - ensure it's a directory path to a .flow folder - if (!flowPath.endsWith(".flow") && !flowPath.endsWith(".flow" + SEP)) { + // Normalize path - ensure it's a directory path to a .flow or __flow folder + const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP) + || flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP); + if (!isFlowDir) { // Check if it's a flow.yaml file if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) { flowPath = flowPath.substring(0, flowPath.lastIndexOf(SEP)); } else { throw new Error( - "Flow path must be a .flow directory or a flow.yaml file" + "Flow path must be a .flow/__flow directory or a flow.yaml file" ); } } @@ -428,7 +451,7 @@ async function preview( } if (opts.silent) { - console.log(JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result)); } else { log.info(colors.bold.underline.green("Flow preview completed")); log.info(JSON.stringify(result, null, 2)); diff --git a/cli/src/commands/folder/folder.ts b/cli/src/commands/folder/folder.ts index eb5a51c4f8..bd298e6f88 100644 --- a/cli/src/commands/folder/folder.ts +++ b/cli/src/commands/folder/folder.ts @@ -22,6 +22,7 @@ export interface FolderFile { } async function list(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts index d273f4b631..7abd155301 100644 --- a/cli/src/commands/generate-metadata/generate-metadata.ts +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -355,71 +355,102 @@ async function generateMetadata( return colors.dim(colors.white(`[${n}/${total}]`.padEnd(maxWidth, " "))); }; + const errors: { path: string; error: string }[] = []; + // Process scripts for (const item of scripts) { current++; log.info(`${formatProgress(current)} script ${item.path}`); - await generateScriptMetadataInternal( - item.path, // originalPath with extension - workspace, - opts, - false, // dryRun - true, // noStaleMessage - mismatchedWorkspaceDeps, - codebases, - false, - false, // legacyBehaviour - tree - ); + try { + await generateScriptMetadataInternal( + item.path, // originalPath with extension + workspace, + opts, + false, // dryRun + true, // noStaleMessage + mismatchedWorkspaceDeps, + codebases, + false, + false, // legacyBehaviour + tree + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push({ path: item.path, error: msg }); + log.error(` Failed: ${msg}`); + } } // Process flows for (const item of flows) { current++; - const result = await generateFlowLockInternal( - item.folder.replaceAll("/", SEP), - false, // dryRun - workspace, - opts, - false, - true, // noStaleMessage - false, // legacyBehaviour - tree - ); - const flowResult = result as FlowLocksResult | undefined; - const scriptsInfo = flowResult?.updatedScripts?.length - ? colors.dim(colors.white(`: ${flowResult.updatedScripts.join(", ")}`)) - : ""; - log.info(`${formatProgress(current)} flow ${item.path}${scriptsInfo}`); + try { + const result = await generateFlowLockInternal( + item.folder.replaceAll("/", SEP), + false, // dryRun + workspace, + opts, + false, + true, // noStaleMessage + false, // legacyBehaviour + tree + ); + const flowResult = result as FlowLocksResult | undefined; + const scriptsInfo = flowResult?.updatedScripts?.length + ? colors.dim(colors.white(`: ${flowResult.updatedScripts.join(", ")}`)) + : ""; + log.info(`${formatProgress(current)} flow ${item.path}${scriptsInfo}`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push({ path: item.path, error: msg }); + log.info(`${formatProgress(current)} flow ${item.path}`); + log.error(` Failed: ${msg}`); + } } // Process apps for (const item of apps) { current++; - const result = await generateAppLocksInternal( - item.folder.replaceAll("/", SEP), - item.isRawApp!, // rawApp - false, // dryRun - workspace, - opts, - false, - true, // noStaleMessage - false, // legacyBehaviour - tree - ); - const appResult = result as AppLocksResult | undefined; - const scriptsInfo = appResult?.updatedScripts?.length - ? colors.dim(colors.white(`: ${appResult.updatedScripts.join(", ")}`)) - : ""; - log.info(`${formatProgress(current)} app ${item.path}${scriptsInfo}`); + try { + const result = await generateAppLocksInternal( + item.folder.replaceAll("/", SEP), + item.isRawApp!, // rawApp + false, // dryRun + workspace, + opts, + false, + true, // noStaleMessage + false, // legacyBehaviour + tree + ); + const appResult = result as AppLocksResult | undefined; + const scriptsInfo = appResult?.updatedScripts?.length + ? colors.dim(colors.white(`: ${appResult.updatedScripts.join(", ")}`)) + : ""; + log.info(`${formatProgress(current)} app ${item.path}${scriptsInfo}`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push({ path: item.path, error: msg }); + log.info(`${formatProgress(current)} app ${item.path}`); + log.error(` Failed: ${msg}`); + } } // Persist all stale workspace dep hashes (not just filtered — deps are global, not folder-scoped) const allStaleDeps = staleItems.filter((i) => i.type === "dependencies"); await tree.persistDepsHashes(allStaleDeps.map((d) => d.path)); + const succeeded = total - errors.length; log.info(""); - log.info(`Done. Updated ${colors.bold(String(total))} item(s).`); + if (errors.length > 0) { + log.info(`Done. Updated ${colors.bold(String(succeeded))}/${total} item(s). ${colors.red(String(errors.length) + " failed")}:`); + for (const { path, error } of errors) { + log.error(` ${path}: ${error}`); + } + process.exitCode = 1; + } else { + log.info(`Done. Updated ${colors.bold(String(total))} item(s).`); + } } const command = new Command() diff --git a/cli/src/commands/instance/instance.ts b/cli/src/commands/instance/instance.ts index 6b22d49b27..d95fe7269b 100644 --- a/cli/src/commands/instance/instance.ts +++ b/cli/src/commands/instance/instance.ts @@ -219,6 +219,22 @@ export async function pickInstance( prefix: opts.prefix ?? "custom", }; } + // Try to use the active workspace profile's remote as a fallback + if (instances.length < 1) { + try { + const ws = await getActiveWorkspace({}); + if (ws?.remote && ws?.token) { + const remote = ws.remote.endsWith("/") ? ws.remote.slice(0, -1) : ws.remote; + setClient(ws.token, remote); + return { + name: ws.name, + remote: ws.remote, + token: ws.token, + prefix: ws.name, + }; + } + } catch { /* ignore */ } + } if (!allowNew && instances.length < 1) { throw new Error("No instance found, please add one first"); } @@ -648,9 +664,27 @@ export async function getActiveInstance(opts: { } } -async function getConfig(opts: InstanceSyncOptions & { outputFile?: string }) { +async function getConfig(opts: InstanceSyncOptions & { outputFile?: string; showSecrets?: boolean }) { await pickInstance(opts, false); - const config = await wmill.getInstanceConfig(); + const config = await wmill.getInstanceConfig() as any; + + // In interactive mode, mask secrets by default and prompt + const hasSecrets = config?.global_settings?.license_key || config?.global_settings?.jwt_secret; + let showSecrets = opts.showSecrets ?? false; + if (!showSecrets && hasSecrets && process.stdout.isTTY && !opts.outputFile) { + log.warn("Config contains sensitive fields (license_key, jwt_secret). They are masked by default."); + log.warn("Use --show-secrets to include them, or press Y to show them now."); + showSecrets = await Confirm.prompt({ message: "Show secrets?", default: false }); + } else if (!process.stdout.isTTY || opts.outputFile) { + // Non-interactive or writing to file: always include secrets + showSecrets = true; + } + + if (!showSecrets && config?.global_settings) { + if (config.global_settings.license_key) config.global_settings.license_key = "***"; + if (config.global_settings.jwt_secret) config.global_settings.jwt_secret = "***"; + } + const yaml = yamlStringify(config as Record); if (opts.outputFile) { await writeFile(opts.outputFile, yaml, "utf-8"); @@ -786,6 +820,7 @@ const command = new Command() .command("get-config") .description("Dump the current instance config (global settings + worker configs) as YAML") .option("-o, --output-file ", "Write YAML to a file instead of stdout") + .option("--show-secrets", "Include sensitive fields (license key, JWT secret) without prompting") .option( "--instance ", "Name of the instance, override the active instance", diff --git a/cli/src/commands/resource-type/resource-type.ts b/cli/src/commands/resource-type/resource-type.ts index 0a5afe8dcb..a4b42582a4 100644 --- a/cli/src/commands/resource-type/resource-type.ts +++ b/cli/src/commands/resource-type/resource-type.ts @@ -88,6 +88,7 @@ async function push(opts: PushOptions, filePath: string, name: string) { } async function list(opts: GlobalOptions & { schema?: boolean; json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); const res = await wmill.listResourceType({ diff --git a/cli/src/commands/resource/resource.ts b/cli/src/commands/resource/resource.ts index e28a479f13..0f35c02293 100644 --- a/cli/src/commands/resource/resource.ts +++ b/cli/src/commands/resource/resource.ts @@ -155,6 +155,7 @@ async function push(opts: PushOptions, filePath: string, remotePath: string) { } async function list(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); let page = 0; diff --git a/cli/src/commands/schedule/schedule.ts b/cli/src/commands/schedule/schedule.ts index c8582c5315..5a2b22efce 100644 --- a/cli/src/commands/schedule/schedule.ts +++ b/cli/src/commands/schedule/schedule.ts @@ -29,6 +29,7 @@ export interface ScheduleFile { } async function list(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -60,7 +61,7 @@ async function newSchedule(opts: GlobalOptions, path: string) { if (e.message?.startsWith("File already exists")) throw e; } const template: ScheduleFile = { - schedule: "0 */6 * * *", + schedule: "0 0 */6 * * *", on_failure: "", script_path: "", args: {}, diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index dd0fd30470..49123a4c2d 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -858,6 +858,7 @@ async function list( json?: boolean; } ) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -920,15 +921,44 @@ async function run( }, path: string ) { + if (opts.silent) { + log.setSilent(true); + } const workspace = await resolveWorkspace(opts); await requireLogin(opts); const input = opts.data ? await resolve(opts.data) : {}; - const id = await wmill.runScriptByPath({ - workspace: workspace.workspaceId, - path, - requestBody: input, - }); + let id: string; + try { + id = await wmill.runScriptByPath({ + workspace: workspace.workspaceId, + path, + requestBody: input, + }); + } catch (e: any) { + if (e?.status === 404) { + // Script might exist but have a lock/deployment error — check before giving up + try { + const script = await wmill.getScriptByPath({ + workspace: workspace.workspaceId, + path, + }); + if (script.lock_error_logs) { + throw new Error( + `Script '${path}' has a deployment error and cannot be run:\n${script.lock_error_logs}` + ); + } + } catch (lookupErr: any) { + if (lookupErr?.message?.includes("deployment error")) throw lookupErr; + // Re-throw non-404 lookup errors (e.g. auth/network issues) + if (lookupErr?.status && lookupErr.status !== 404) throw lookupErr; + } + throw new Error( + `Script '${path}' not found. Run 'wmill script list' to see available scripts.` + ); + } + throw e; + } if (!opts.silent) { await track_job(workspace.workspaceId, id); @@ -945,7 +975,7 @@ async function run( ).result ?? {}; if (opts.silent) { - console.log(result); + console.log(JSON.stringify(result)); } else { log.info(JSON.stringify(result, null, 2)); } @@ -1087,7 +1117,10 @@ async function bootstrap( const scriptInitialCode = scriptBootstrapCode[resolvedLanguage]; if (scriptInitialCode === undefined) { - throw new Error("Language unknown"); + const validLanguages = Object.keys(scriptBootstrapCode).sort().join(", "); + throw new Error( + `Unknown language '${language}'. Valid languages: ${validLanguages}` + ); } const config = await readConfigFile(); @@ -1262,6 +1295,9 @@ async function preview( } & SyncOptions, filePath: string ) { + if (opts.silent) { + log.setSilent(true); + } opts = await mergeConfigWithConfigFile(opts); const workspace = await resolveWorkspace(opts); await requireLogin(opts); diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index f2d15916d6..348117a7ff 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -1987,9 +1987,15 @@ export async function pull( opts: GlobalOptions & SyncOptions & { repository?: string; promotion?: string; branch?: string }, ) { + if ((opts as any).jsonOutput) log.setSilent(true); const originalCliOpts = { ...opts }; opts = await mergeConfigWithConfigFile(opts); + // --include-secrets overrides skipSecrets from wmill.yaml + if ((originalCliOpts as any).includeSecrets) { + opts.skipSecrets = false; + } + // Validate branch configuration early (skipped when --branch is used) try { await validateBranchConfiguration(opts, opts.branch); @@ -2478,12 +2484,18 @@ function removeSuffix(str: string, suffix: string) { export async function push( opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string }, ) { + if ((opts as any).jsonOutput) log.setSilent(true); // Save original CLI options before merging with config file const originalCliOpts = { ...opts }; // Load configuration from wmill.yaml and merge with CLI options opts = await mergeConfigWithConfigFile(opts); + // --include-secrets overrides skipSecrets from wmill.yaml + if ((originalCliOpts as any).includeSecrets) { + opts.skipSecrets = false; + } + // Validate branch configuration early (skipped when --branch is used) try { await validateBranchConfiguration(opts, opts.branch); @@ -2617,6 +2629,7 @@ export async function push( const tracker: ChangeTracker = await buildTracker(changes); + const autoRegenerate = !!(opts as any).autoMetadata; const staleScripts: string[] = []; const staleFlows: string[] = []; const staleApps: string[] = []; @@ -2626,7 +2639,7 @@ export async function push( change, workspace, opts, - true, + !autoRegenerate, // dryRun=false when --auto is set true, rawWorkspaceDependencies, codebases, @@ -2639,11 +2652,19 @@ export async function push( if (staleScripts.length > 0) { log.info(""); - log.warn( - "Stale scripts metadata found, you may want to update them using 'wmill script generate-metadata' before pushing:", - ); + if (autoRegenerate) { + log.info("Auto-regenerated metadata for stale scripts:"); + } else { + log.warn( + "Stale scripts metadata found, you may want to update them using 'wmill script generate-metadata' before pushing:", + ); + } for (const stale of staleScripts) { - log.warn(stale); + if (autoRegenerate) { + log.info(` ${stale}`); + } else { + log.warn(stale); + } } log.info(""); @@ -2652,7 +2673,7 @@ export async function push( for (const change of tracker.flows) { const stale = await generateFlowLockInternal( change, - true, + !autoRegenerate, // dryRun=false when --auto is set workspace, opts, false, @@ -2664,11 +2685,19 @@ export async function push( } if (staleFlows.length > 0) { - log.warn( - "Stale flows locks found, you may want to update them using 'wmill flow generate-locks' before pushing:", - ); + if (autoRegenerate) { + log.info("Auto-regenerated locks for stale flows:"); + } else { + log.warn( + "Stale flows locks found, you may want to update them using 'wmill flow generate-locks' before pushing:", + ); + } for (const stale of staleFlows) { - log.warn(stale); + if (autoRegenerate) { + log.info(` ${stale}`); + } else { + log.warn(stale); + } } log.info(""); } @@ -2677,7 +2706,7 @@ export async function push( const stale = await generateAppLocksInternal( change, false, - true, + !autoRegenerate, workspace, opts, true, @@ -2692,7 +2721,7 @@ export async function push( const stale = await generateAppLocksInternal( change, true, - true, + !autoRegenerate, workspace, opts, true, @@ -2704,15 +2733,46 @@ export async function push( } if (staleApps.length > 0) { - log.warn( - "Stale apps locks found, you may want to update them using 'wmill app generate-locks' before pushing:", - ); + if (autoRegenerate) { + log.info("Auto-regenerated locks for stale apps:"); + } else { + log.warn( + "Stale apps locks found, you may want to update them using 'wmill app generate-locks' before pushing:", + ); + } for (const stale of staleApps) { - log.warn(stale); + if (autoRegenerate) { + log.info(` ${stale}`); + } else { + log.warn(stale); + } } log.info(""); } + // Warn about local files for skipped types. Walks the in-memory DynFSElement tree + // (not a fresh disk scan), but does re-traverse it. Acceptable cost for a one-time check. + { + const skippedWarnings: string[] = []; + let scheduleCount = 0; + let triggerCount = 0; + for await (const entry of readDirRecursiveWithIgnore(() => false, local)) { + if (entry.isDirectory) continue; + if (!opts.includeSchedules && entry.path.endsWith(".schedule.yaml")) scheduleCount++; + if (!opts.includeTriggers && entry.path.endsWith("_trigger.yaml")) triggerCount++; + } + if (scheduleCount > 0) { + skippedWarnings.push(`Skipping ${scheduleCount} schedule file(s). Use --include-schedules or set includeSchedules: true in wmill.yaml`); + } + if (triggerCount > 0) { + skippedWarnings.push(`Skipping ${triggerCount} trigger file(s). Use --include-triggers or set includeTriggers: true in wmill.yaml`); + } + for (const warning of skippedWarnings) { + log.warn(warning); + } + if (skippedWarnings.length > 0) log.info(""); + } + await fetchRemoteVersion(workspace); log.info( @@ -3522,6 +3582,7 @@ const command = new Command() .option("--json", "Use JSON instead of YAML") .option("--skip-variables", "Skip syncing variables (including secrets)") .option("--skip-secrets", "Skip syncing only secrets variables") + .option("--include-secrets", "Include secrets in sync (overrides skipSecrets in wmill.yaml)") .option("--skip-resources", "Skip syncing resources") .option("--skip-resource-types", "Skip syncing resource types") .option("--skip-scripts", "Skip syncing scripts") @@ -3577,6 +3638,7 @@ const command = new Command() .option("--json", "Use JSON instead of YAML") .option("--skip-variables", "Skip syncing variables (including secrets)") .option("--skip-secrets", "Skip syncing only secrets variables") + .option("--include-secrets", "Include secrets in sync (overrides skipSecrets in wmill.yaml)") .option("--skip-resources", "Skip syncing resources") .option("--skip-resource-types", "Skip syncing resource types") .option("--skip-scripts", "Skip syncing scripts") @@ -3626,6 +3688,7 @@ const command = new Command() "--locks-required", "Fail if scripts or flow inline scripts that need locks have no locks", ) + .option("--auto-metadata", "Automatically regenerate stale metadata (locks and schemas) before pushing") .action(push as any); export default command; diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index 11f68bea96..d3c61114fc 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -308,11 +308,20 @@ const triggerTemplates: Record> = { http_method: "get", is_async: false, requires_auth: true, + request_type: "sync", + authentication_method: "none", + is_static_website: false, + workspaced_route: false, + wrap_body: false, + raw_string: false, }, websocket: { script_path: "", is_flow: false, url: "", + filters: [], + can_return_message: false, + can_return_error_result: false, enabled: false, }, kafka: { @@ -321,6 +330,7 @@ const triggerTemplates: Record> = { kafka_resource_path: "", group_id: "", topics: [], + filters: [], enabled: false, }, nats: { @@ -328,6 +338,7 @@ const triggerTemplates: Record> = { is_flow: false, nats_resource_path: "", subjects: [], + use_jetstream: false, enabled: false, }, postgres: { @@ -342,23 +353,25 @@ const triggerTemplates: Record> = { script_path: "", is_flow: false, mqtt_resource_path: "", - topics: [], - subscribe_qos: 0, + subscribe_topics: [], enabled: false, }, sqs: { script_path: "", is_flow: false, - sqs_resource_path: "", queue_url: "", + aws_resource_path: "", + aws_auth_resource_type: "credentials", enabled: false, }, gcp: { script_path: "", is_flow: false, gcp_resource_path: "", - subscription_id: "", topic_id: "", + subscription_id: "", + delivery_type: "pull", + subscription_mode: "create_update", enabled: false, }, email: { @@ -437,7 +450,7 @@ async function get(opts: GlobalOptions & { json?: boolean; kind?: string }, path } else { console.log(colors.bold("Path:") + " " + trigger.path); console.log(colors.bold("Kind:") + " " + kind); - console.log(colors.bold("Enabled:") + " " + (trigger.enabled ?? "-")); + console.log(colors.bold("Enabled:") + " " + (trigger.enabled ?? (trigger as any).mode ?? "-")); console.log(colors.bold("Script Path:") + " " + (trigger.script_path ?? "")); console.log(colors.bold("Is Flow:") + " " + (trigger.is_flow ? "true" : "false")); } @@ -461,6 +474,7 @@ async function listOrEmpty(fn: () => Promise): Promise { } async function list(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); diff --git a/cli/src/commands/user/user.ts b/cli/src/commands/user/user.ts index 207958ecce..b5ed0e3196 100644 --- a/cli/src/commands/user/user.ts +++ b/cli/src/commands/user/user.ts @@ -530,7 +530,7 @@ const command = new Command() .command("remove", "Delete a user") .arguments("") .action(remove as any) - .command("create-token") + .command("create-token", "Create a new API token for the authenticated user") .option( "--email ", "Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.", diff --git a/cli/src/commands/variable/variable.ts b/cli/src/commands/variable/variable.ts index 21b7a69eba..c01c083706 100644 --- a/cli/src/commands/variable/variable.ts +++ b/cli/src/commands/variable/variable.ts @@ -20,6 +20,7 @@ import * as wmill from "../../../gen/services.gen.ts"; import { ListableVariable } from "../../../gen/types.gen.ts"; async function list(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index 619f29fa2c..92bb38b799 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -129,10 +129,16 @@ async function createWorkspaceFork( const newBranchName = `${WM_FORK_PREFIX}/${clonedBranchName}/${workspaceId}` log.info(`Created forked workspace ${trueWorkspaceId}. To start contributing to your fork, create and push edits to the branch \`${newBranchName}\` by using the command: - + \t`+colors.white(`git checkout -b ${newBranchName}`) + ` - -When doing operations on the forked workspace, it will use the remote setup in gitBranches for the branch it was forked from.`); + +When doing operations on the forked workspace, it will use the remote setup in gitBranches for the branch it was forked from. + +To merge changes back to the parent workspace, you can: + - Use the Merge UI from the forked workspace home page + - Deploy individual items via the Deploy to staging/prod UI + - Use git: ` + colors.white(`git checkout ${clonedBranchName} && git merge ${newBranchName} && wmill sync push`) + ` + See: https://www.windmill.dev/docs/advanced/workspace_forks`); } async function deleteWorkspaceFork( @@ -141,54 +147,69 @@ async function deleteWorkspaceFork( }, name: string, ) { + let forkWorkspaceId: string; + let token: string; + let remote: string; + let hasLocalProfile = false; + + // Try local profile first (existing behavior) const orgWorkspaces = await allWorkspaces(opts.configDir); - const idxOf = orgWorkspaces.findIndex((x) => x.name === name) ; - if (idxOf === -1) { - log.info( - colors.red.bold(`! Workspace profile ${name} does not exist locally`) - ); - log.info("available workspace profiles:"); - await list(opts); - return; - } + const idxOf = orgWorkspaces.findIndex((x) => x.name === name); - const workspace = orgWorkspaces[idxOf]; - - if (!workspace.workspaceId.startsWith(WM_FORK_PREFIX)) { + if (idxOf !== -1) { + const workspace = orgWorkspaces[idxOf]; + if (!workspace.workspaceId.startsWith(WM_FORK_PREFIX)) { throw new Error( `You can only delete forked workspaces where the workspace id starts with \`${WM_FORK_PREFIX}.\` Failed while attempting to delete \`${workspace.workspaceId}\``, ); + } + forkWorkspaceId = workspace.workspaceId; + token = workspace.token; + remote = workspace.remote; + hasLocalProfile = true; + } else { + // Fallback: resolve parent workspace from branch config and construct fork ID + const parentWorkspace = await tryResolveBranchWorkspace(opts); + if (!parentWorkspace) { + throw new Error( + "Could not resolve parent workspace. Make sure you are in a git repo with gitBranches configured in wmill.yaml, or create a local workspace profile for the fork.", + ); + } + forkWorkspaceId = name.startsWith(`${WM_FORK_PREFIX}-`) ? name : `${WM_FORK_PREFIX}-${name}`; + token = parentWorkspace.token; + remote = parentWorkspace.remote; } if (!opts.yes) { - const { Select } = await import("@cliffy/prompt/select"); - const choice = await Select.prompt({ - message: `Are you sure you want to delete the forked workspace with id: \`${workspace.workspaceId}\`? This action will delete the workspace `, - options: [ - { name: "Yes", value: "confirm" }, - { name: "No", value: "cancel" }, - ], - }); + const { Select } = await import("@cliffy/prompt/select"); + const choice = await Select.prompt({ + message: `Are you sure you want to delete the forked workspace \`${forkWorkspaceId}\`?`, + options: [ + { name: "Yes", value: "confirm" }, + { name: "No", value: "cancel" }, + ], + }); - if (choice === "cancel") { - log.info("Operation cancelled"); - return; - } + if (choice === "cancel") { + log.info("Operation cancelled"); + return; + } } - const remote = workspace.remote setClient( - workspace.token, + token, remote.endsWith("/") ? remote.substring(0, remote.length - 1) : remote ); const result = await wmill.deleteWorkspace({ - workspace: workspace.workspaceId + workspace: forkWorkspaceId }); log.info( - colors.green(`✅ Forked workspace '${workspace.workspaceId}' deleted successfully!\n${result}`), + colors.green(`✅ Forked workspace '${forkWorkspaceId}' deleted successfully!\n${result}`), ); - await removeWorkspace(name, false, opts); + if (hasLocalProfile) { + await removeWorkspace(name, false, opts); + } } export { createWorkspaceFork, deleteWorkspaceFork }; diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index d82290e743..c51544fe90 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -253,8 +253,12 @@ export async function add( "On that instance and with those credentials, the workspaces that you can access are:" ); const workspaces = await wmill.listWorkspaces(); - for (const workspace of workspaces) { - log.info(`- ${workspace.id} (name: ${workspace.name})`); + if (workspaces.length === 0) { + log.info(" (none)"); + } else { + for (const workspace of workspaces) { + log.info(`- ${workspace.id} (name: ${workspace.name})`); + } } process.exit(1); } @@ -411,31 +415,94 @@ async function whoami(_opts: GlobalOptions) { const whoamiInfo = await wmill.globalWhoami(); log.info(JSON.stringify(whoamiInfo, null, 2)); const activeName = await getActiveWorkspaceName(_opts); - log.info("Active: " + colors.green.bold(activeName || "none")); + const { getCurrentGitBranch, getOriginalBranchForWorkspaceForks } = await import("../../utils/git.ts"); + const branch = getCurrentGitBranch(); + const originalBranch = branch ? getOriginalBranchForWorkspaceForks(branch) : null; + if (originalBranch) { + const { resolveWorkspace } = await import("../../core/context.ts"); + try { + const ws = await resolveWorkspace(_opts); + log.info("Active: " + colors.green.bold(`${activeName || "none"}`) + ` (fork workspace: ${ws.workspaceId})`); + } catch { + log.info("Active: " + colors.green.bold(activeName || "none") + " (fork branch)"); + } + } else { + log.info("Active: " + colors.green.bold(activeName || "none")); + } } async function listRemote(_opts: GlobalOptions) { - const { resolveWorkspace } = await import("../../core/context.ts"); - const workspace = await resolveWorkspace(_opts); - await requireLogin(_opts); + let remote: string; + + if (_opts.baseUrl && _opts.token && !_opts.workspace) { + // Allow listing workspaces with just --base-url and --token (no --workspace needed) + const { setClient } = await import("../../core/client.ts"); + remote = new URL(_opts.baseUrl).toString(); + setClient(_opts.token, remote.replace(/\/$/, "")); + } else { + const { resolveWorkspace } = await import("../../core/context.ts"); + const workspace = await resolveWorkspace(_opts); + await requireLogin(_opts); + remote = workspace.remote; + } + const userWorkspaces = await wmill.listUserWorkspaces(); + const hasForks = userWorkspaces.workspaces.some((x) => x.parent_workspace_id); + const headers = hasForks + ? ["id", "name", "username", "fork of", "disabled"] + : ["id", "name", "username", "disabled"]; + new Table() - .header(["id", "name", "username", "disabled"]) + .header(headers) .padding(2) .border(true) .body( - userWorkspaces.workspaces.map((x) => [ + userWorkspaces.workspaces.map((x) => { + const row = [ + x.id, + x.name, + x.username, + ]; + if (hasForks) row.push(x.parent_workspace_id ?? "-"); + row.push(x.disabled ? colors.red("true") : "false"); + return row; + }) + ) + .render(); + + log.info(`Remote: ${colors.bold(remote)}`); + log.info(`Logged in as: ${colors.green.bold(userWorkspaces.email)}`); +} + +async function listForks(_opts: GlobalOptions) { + const { resolveWorkspace } = await import("../../core/context.ts"); + const workspace = await resolveWorkspace(_opts); + await requireLogin(_opts); + + const userWorkspaces = await wmill.listUserWorkspaces(); + const forks = userWorkspaces.workspaces.filter((w) => w.parent_workspace_id); + + if (forks.length === 0) { + log.info("No forked workspaces found."); + return; + } + + new Table() + .header(["id", "name", "fork of", "username"]) + .padding(2) + .border(true) + .body( + forks.map((x) => [ x.id, x.name, + x.parent_workspace_id ?? "", x.username, - x.disabled ? colors.red("true") : "false", ]) ) .render(); log.info(`Remote: ${colors.bold(workspace.remote)}`); - log.info(`Logged in as: ${colors.green.bold(userWorkspaces.email)}`); } export async function getActiveWorkspaceOrFallback(opts: GlobalOptions) { @@ -566,8 +633,11 @@ const command = new Command() .command("list-remote") .description("List workspaces on the remote server that you have access to") .action(listRemote as any) + .command("list-forks") + .description("List forked workspaces on the remote server") + .action(listForks as any) .command("bind") - .description("Bind the current Git branch to the active workspace") + .description("Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch.") .option("--branch, --env ", "Specify branch/environment (defaults to current)") .action((opts) => bind(opts as any, true)) .command("unbind") diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 4a4aec7f25..826b207323 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -195,15 +195,18 @@ export function getWmillYamlPath(): string | null { return findWmillYaml(); } -export async function readConfigFile(): Promise { +export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promise { + const warnIfMissing = opts?.warnIfMissing ?? true; try { // First, try to find wmill.yaml recursively const wmillYamlPath = findWmillYaml(); if (!wmillYamlPath) { - log.warn( - "No wmill.yaml found. Use 'wmill init' to bootstrap it." - ); + if (warnIfMissing) { + log.warn( + "No wmill.yaml found. Use 'wmill init' to bootstrap it." + ); + } return {}; } diff --git a/cli/src/core/context.ts b/cli/src/core/context.ts index 2b7a2266e2..6ff1461960 100644 --- a/cli/src/core/context.ts +++ b/cli/src/core/context.ts @@ -262,8 +262,8 @@ export async function tryResolveBranchWorkspace( } } - // Read wmill.yaml to check for branch workspace configuration - const config = await readConfigFile(); + // Read wmill.yaml to check for branch workspace configuration (silent — just probing) + const config = await readConfigFile({ warnIfMissing: false }); const branchConfig = config.gitBranches?.[currentBranch]; // Check if branch has workspace configuration @@ -458,15 +458,16 @@ export async function resolveWorkspace( const branch = branchOverride ?? getCurrentGitBranch(); // Try explicit workspace flag first (should override branch-based resolution). Unless it's a - // forked workspace, that we detect through the branch name (only when not using branchOverride) + // forked workspace, that we detect through the branch name (only when not using branchOverride + // and --workspace was not explicitly provided) const res = await tryResolveWorkspace(opts); if (!res.isError) { const workspace = (res as { isError: false; value: Workspace }).value; - if (branchOverride || !branch || !branch.startsWith(WM_FORK_PREFIX)) { + if (branchOverride || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) { return workspace; } else { log.info( - `Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`` + `Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`. Use --workspace to override.` ); } } else if (opts.workspace) { diff --git a/cli/src/core/log.ts b/cli/src/core/log.ts index d7bed9a0d4..034e13e3e1 100644 --- a/cli/src/core/log.ts +++ b/cli/src/core/log.ts @@ -1,4 +1,5 @@ let logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR" = "INFO"; +let silentMode = false; const levels = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }; @@ -6,19 +7,25 @@ export function setup(level: "DEBUG" | "INFO" | "WARN" | "ERROR") { logLevel = level; } +export function setSilent(silent: boolean) { + silentMode = silent; +} + export function debug(msg: unknown) { if (levels[logLevel] <= levels.DEBUG) console.log(`\x1b[90m${String(msg)}\x1b[39m`); } export function info(msg: unknown) { + if (silentMode) return; console.log(`\x1b[34m${String(msg)}\x1b[39m`); } export function warn(msg: unknown) { + if (silentMode) return; console.log(`\x1b[33m${String(msg)}\x1b[39m`); } export function error(msg: unknown) { - console.log(`\x1b[31m${String(msg)}\x1b[39m`); + console.error(`\x1b[31m${String(msg)}\x1b[39m`); } diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 7da9abd72e..0d9da7dc3a 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -5018,14 +5018,14 @@ workspace dependencies related commands ### dev -Launch a dev server that will spawn a webserver with HMR +Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development. **Options:** - \`--includes \` - Filter paths givena glob pattern or path ### docs -Search Windmill documentation. Requires Enterprise Edition. +Search Windmill documentation. **Arguments:** \`\` @@ -5183,6 +5183,7 @@ sync local with a remote instance or the opposite (push or pull) - \`instance whoami\` - Display information about the currently logged-in user - \`instance get-config\` - Dump the current instance config (global settings + worker configs) as YAML - \`-o, --output-file \` - Write YAML to a file instead of stdout + - \`--show-secrets\` - Include sensitive fields (license key, JWT secret) without prompting - \`--instance \` - Name of the instance, override the active instance ### jobs @@ -5322,6 +5323,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--json\` - Use JSON instead of YAML - \`--skip-variables\` - Skip syncing variables (including secrets) - \`--skip-secrets\` - Skip syncing only secrets variables + - \`--include-secrets\` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - \`--skip-resources\` - Skip syncing resources - \`--skip-resource-types\` - Skip syncing resource types - \`--skip-scripts\` - Skip syncing scripts @@ -5351,6 +5353,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--json\` - Use JSON instead of YAML - \`--skip-variables\` - Skip syncing variables (including secrets) - \`--skip-secrets\` - Skip syncing only secrets variables + - \`--include-secrets\` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - \`--skip-resources\` - Skip syncing resources - \`--skip-resource-types\` - Skip syncing resource types - \`--skip-scripts\` - Skip syncing scripts @@ -5376,6 +5379,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) - \`--lint\` - Run lint validation before pushing - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks + - \`--auto-metadata\` - Automatically regenerate stale metadata (locks and schemas) before pushing ### trigger @@ -5406,7 +5410,7 @@ user related commands - \`--company \` - Specify to set the company of the new user. - \`--name \` - Specify to set the name of the new user. - \`user remove \` - Delete a user -- \`user create-token\` +- \`user create-token\` - Create a new API token for the authenticated user - \`--email \` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. - \`--password \` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. @@ -5474,7 +5478,8 @@ workspace related commands - \`workspace whoami\` - Show the currently active user - \`workspace list\` - List local workspace profiles - \`workspace list-remote\` - List workspaces on the remote server that you have access to -- \`workspace bind\` - Bind the current Git branch to the active workspace +- \`workspace list-forks\` - List forked workspaces on the remote server +- \`workspace bind\` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - \`--branch, --env \` - Specify branch/environment (defaults to current) - \`workspace unbind\` - Remove workspace binding from the current Git branch - \`--branch, --env \` - Specify branch/environment (defaults to current) diff --git a/cli/src/main.ts b/cli/src/main.ts index 2cb9f1d6d4..4446b53721 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -218,11 +218,22 @@ async function main() { await command.parse(args); } catch (e) { if (e && typeof e === "object" && "name" in e && e.name === "ApiError") { - console.log( - "Server failed. " + (e as any).statusText + ": " + (e as any).body + const body = (e as any).body; + const bodyStr = typeof body === "object" && body !== null ? JSON.stringify(body) : body; + log.error( + "Server failed. " + (e as any).statusText + ": " + bodyStr ); + } else if (e instanceof Error) { + log.error(e.message); + } else if (e !== undefined && e !== null) { + log.error(String(e)); } - throw e; + const isDebug = + process.argv.includes("--verbose") || process.argv.includes("--debug"); + if (isDebug) { + throw e; + } + process.exitCode = 1; } } diff --git a/cli/src/types.ts b/cli/src/types.ts index 56fbf384fa..65157ea75c 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -358,12 +358,16 @@ export function removeType(str: string, type: string) { const normalizedStr = path.normalize(str).replaceAll(SEP, "/"); if ( - !normalizedStr.endsWith("." + type + ".yaml") && - !normalizedStr.endsWith("." + type + ".json") + normalizedStr.endsWith("." + type + ".yaml") || + normalizedStr.endsWith("." + type + ".json") ) { - throw new Error(str + " does not end with ." + type + ".(yaml|json)"); + return normalizedStr.slice(0, normalizedStr.length - type.length - 6); } - return normalizedStr.slice(0, normalizedStr.length - type.length - 6); + // Accept clean paths without the type suffix (e.g. "f/folder/name" instead of "f/folder/name.schedule.yaml") + if (normalizedStr.includes("." + type)) { + log.debug(`Path '${str}' contains '.${type}' but doesn't end with '.${type}.(yaml|json)' — treating as clean path`); + } + return normalizedStr; } /** diff --git a/cli/test/utils_unit.test.ts b/cli/test/utils_unit.test.ts index 7bc3985a9c..288814987e 100644 --- a/cli/test/utils_unit.test.ts +++ b/cli/test/utils_unit.test.ts @@ -203,12 +203,12 @@ describe("removeType", () => { expect(removeType("f/test/my_var.variable.json", "variable")).toBe("f/test/my_var"); }); - test("throws for wrong type suffix", () => { - expect(() => removeType("f/test/my_var.variable.yaml", "resource")).toThrow(); + test("passes through path with wrong type suffix as clean path", () => { + expect(removeType("f/test/my_var.variable.yaml", "resource")).toBe("f/test/my_var.variable.yaml"); }); - test("throws for no type suffix", () => { - expect(() => removeType("f/test/my_script.ts", "variable")).toThrow(); + test("passes through path with no type suffix as clean path", () => { + expect(removeType("f/test/my_script.ts", "variable")).toBe("f/test/my_script.ts"); }); }); diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index c582a277b4..4c38b2ffc2 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -60,14 +60,14 @@ workspace dependencies related commands ### dev -Launch a dev server that will spawn a webserver with HMR +Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development. **Options:** - `--includes ` - Filter paths givena glob pattern or path ### docs -Search Windmill documentation. Requires Enterprise Edition. +Search Windmill documentation. **Arguments:** `` @@ -225,6 +225,7 @@ sync local with a remote instance or the opposite (push or pull) - `instance whoami` - Display information about the currently logged-in user - `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML - `-o, --output-file ` - Write YAML to a file instead of stdout + - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting - `--instance ` - Name of the instance, override the active instance ### jobs @@ -364,6 +365,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--json` - Use JSON instead of YAML - `--skip-variables` - Skip syncing variables (including secrets) - `--skip-secrets` - Skip syncing only secrets variables + - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - `--skip-resources` - Skip syncing resources - `--skip-resource-types` - Skip syncing resource types - `--skip-scripts` - Skip syncing scripts @@ -393,6 +395,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--json` - Use JSON instead of YAML - `--skip-variables` - Skip syncing variables (including secrets) - `--skip-secrets` - Skip syncing only secrets variables + - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - `--skip-resources` - Skip syncing resources - `--skip-resource-types` - Skip syncing resource types - `--skip-scripts` - Skip syncing scripts @@ -418,6 +421,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) - `--lint` - Run lint validation before pushing - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks + - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing ### trigger @@ -448,7 +452,7 @@ user related commands - `--company ` - Specify to set the company of the new user. - `--name ` - Specify to set the name of the new user. - `user remove ` - Delete a user -- `user create-token` +- `user create-token` - Create a new API token for the authenticated user - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. @@ -516,7 +520,8 @@ workspace related commands - `workspace whoami` - Show the currently active user - `workspace list` - List local workspace profiles - `workspace list-remote` - List workspaces on the remote server that you have access to -- `workspace bind` - Bind the current Git branch to the active workspace +- `workspace list-forks` - List forked workspaces on the remote server +- `workspace bind` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - `--branch, --env ` - Specify branch/environment (defaults to current) - `workspace unbind` - Remove workspace binding from the current Git branch - `--branch, --env ` - Specify branch/environment (defaults to current) diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 106c629021..e6fa904052 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1587,14 +1587,14 @@ workspace dependencies related commands ### dev -Launch a dev server that will spawn a webserver with HMR +Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development. **Options:** - \`--includes \` - Filter paths givena glob pattern or path ### docs -Search Windmill documentation. Requires Enterprise Edition. +Search Windmill documentation. **Arguments:** \`\` @@ -1752,6 +1752,7 @@ sync local with a remote instance or the opposite (push or pull) - \`instance whoami\` - Display information about the currently logged-in user - \`instance get-config\` - Dump the current instance config (global settings + worker configs) as YAML - \`-o, --output-file \` - Write YAML to a file instead of stdout + - \`--show-secrets\` - Include sensitive fields (license key, JWT secret) without prompting - \`--instance \` - Name of the instance, override the active instance ### jobs @@ -1891,6 +1892,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--json\` - Use JSON instead of YAML - \`--skip-variables\` - Skip syncing variables (including secrets) - \`--skip-secrets\` - Skip syncing only secrets variables + - \`--include-secrets\` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - \`--skip-resources\` - Skip syncing resources - \`--skip-resource-types\` - Skip syncing resource types - \`--skip-scripts\` - Skip syncing scripts @@ -1920,6 +1922,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--json\` - Use JSON instead of YAML - \`--skip-variables\` - Skip syncing variables (including secrets) - \`--skip-secrets\` - Skip syncing only secrets variables + - \`--include-secrets\` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - \`--skip-resources\` - Skip syncing resources - \`--skip-resource-types\` - Skip syncing resource types - \`--skip-scripts\` - Skip syncing scripts @@ -1945,6 +1948,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) - \`--lint\` - Run lint validation before pushing - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks + - \`--auto-metadata\` - Automatically regenerate stale metadata (locks and schemas) before pushing ### trigger @@ -1975,7 +1979,7 @@ user related commands - \`--company \` - Specify to set the company of the new user. - \`--name \` - Specify to set the name of the new user. - \`user remove \` - Delete a user -- \`user create-token\` +- \`user create-token\` - Create a new API token for the authenticated user - \`--email \` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. - \`--password \` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. @@ -2043,7 +2047,8 @@ workspace related commands - \`workspace whoami\` - Show the currently active user - \`workspace list\` - List local workspace profiles - \`workspace list-remote\` - List workspaces on the remote server that you have access to -- \`workspace bind\` - Bind the current Git branch to the active workspace +- \`workspace list-forks\` - List forked workspaces on the remote server +- \`workspace bind\` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - \`--branch, --env \` - Specify branch/environment (defaults to current) - \`workspace unbind\` - Remove workspace binding from the current Git branch - \`--branch, --env \` - Specify branch/environment (defaults to current) diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 30c31c4bcb..8b95426b37 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -65,14 +65,14 @@ workspace dependencies related commands ### dev -Launch a dev server that will spawn a webserver with HMR +Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development. **Options:** - `--includes ` - Filter paths givena glob pattern or path ### docs -Search Windmill documentation. Requires Enterprise Edition. +Search Windmill documentation. **Arguments:** `` @@ -230,6 +230,7 @@ sync local with a remote instance or the opposite (push or pull) - `instance whoami` - Display information about the currently logged-in user - `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML - `-o, --output-file ` - Write YAML to a file instead of stdout + - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting - `--instance ` - Name of the instance, override the active instance ### jobs @@ -369,6 +370,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--json` - Use JSON instead of YAML - `--skip-variables` - Skip syncing variables (including secrets) - `--skip-secrets` - Skip syncing only secrets variables + - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - `--skip-resources` - Skip syncing resources - `--skip-resource-types` - Skip syncing resource types - `--skip-scripts` - Skip syncing scripts @@ -398,6 +400,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--json` - Use JSON instead of YAML - `--skip-variables` - Skip syncing variables (including secrets) - `--skip-secrets` - Skip syncing only secrets variables + - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - `--skip-resources` - Skip syncing resources - `--skip-resource-types` - Skip syncing resource types - `--skip-scripts` - Skip syncing scripts @@ -423,6 +426,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) - `--lint` - Run lint validation before pushing - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks + - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing ### trigger @@ -453,7 +457,7 @@ user related commands - `--company ` - Specify to set the company of the new user. - `--name ` - Specify to set the name of the new user. - `user remove ` - Delete a user -- `user create-token` +- `user create-token` - Create a new API token for the authenticated user - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. @@ -521,7 +525,8 @@ workspace related commands - `workspace whoami` - Show the currently active user - `workspace list` - List local workspace profiles - `workspace list-remote` - List workspaces on the remote server that you have access to -- `workspace bind` - Bind the current Git branch to the active workspace +- `workspace list-forks` - List forked workspaces on the remote server +- `workspace bind` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - `--branch, --env ` - Specify branch/environment (defaults to current) - `workspace unbind` - Remove workspace binding from the current Git branch - `--branch, --env ` - Specify branch/environment (defaults to current) From 820f28f8799f8dad5cfab94b51ac9921d664f04a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 28 Mar 2026 08:53:40 +0000 Subject: [PATCH 093/153] fix: trigger capture filter and focus issues (#8579) * fix: replace label with div for filter value editor to fix focus stealing Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 02c0d34e54e71c9293f9cefb56f68652cf0db8a5 This commit updates the EE repository reference after PR #497 was merged in windmill-ee-private. Previous ee-repo-ref: 44d665af35ad23cd3549b1d094f5d6633237deb4 New ee-repo-ref: 02c0d34e54e71c9293f9cefb56f68652cf0db8a5 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- frontend/src/lib/components/triggers/TriggerFilters.svelte | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e92e39c3c1..1d141c15fb 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -9316adc693d7f1a668df661e000109bb48b93375 +02c0d34e54e71c9293f9cefb56f68652cf0db8a5 diff --git a/frontend/src/lib/components/triggers/TriggerFilters.svelte b/frontend/src/lib/components/triggers/TriggerFilters.svelte index e2657f6cbd..1fb4041b48 100644 --- a/frontend/src/lib/components/triggers/TriggerFilters.svelte +++ b/frontend/src/lib/components/triggers/TriggerFilters.svelte @@ -26,11 +26,10 @@
    Key
    - -