From f2425362f9fdce945ed69137e852202b52c76e6e Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 19 Jun 2025 16:48:16 +0100 Subject: [PATCH 01/25] display branchone default branch label (#6005) --- .../lib/components/graph/renderers/nodes/NoBranchNode.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/graph/renderers/nodes/NoBranchNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/NoBranchNode.svelte index da9284c6dd..dc6986fe52 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/NoBranchNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/NoBranchNode.svelte @@ -14,7 +14,7 @@ {#snippet children({ darkMode })} Date: Thu, 19 Jun 2025 17:49:53 +0200 Subject: [PATCH 02/25] Remove last SelectLegacy (#6003) * Replace SelectLegacy in AppPicker * Better DynSelect * remove select legacy * fix unknown values not displayed * undo tsconfig change * better dyn select search * index js remove * use bindable defaults --- frontend/src/lib/components/DynSelect.svelte | 162 +-- .../svelte-select/lib/SelectLegacy.svelte | 1043 ----------------- .../apps/svelte-select/lib/index.js | 1 - .../lib/components/select/MultiSelect.svelte | 2 +- .../src/lib/components/select/Select.svelte | 19 +- .../components/select/SelectDropdown.svelte | 2 +- .../lib/components/wizards/AppPicker.svelte | 39 +- frontend/src/lib/utils.ts | 10 +- 8 files changed, 92 insertions(+), 1186 deletions(-) delete mode 100644 frontend/src/lib/components/apps/svelte-select/lib/SelectLegacy.svelte delete mode 100644 frontend/src/lib/components/apps/svelte-select/lib/index.js diff --git a/frontend/src/lib/components/DynSelect.svelte b/frontend/src/lib/components/DynSelect.svelte index 0241ab90f5..93a3bf0ed3 100644 --- a/frontend/src/lib/components/DynSelect.svelte +++ b/frontend/src/lib/components/DynSelect.svelte @@ -1,26 +1,4 @@ - + + {#if helperScript} -
-
- {#key renderCount} - { - error = e.detail.details - }} - on:change={(e) => { - value = e.detail.value - }} - {value} - computeOnClick={value == undefined} - loadOptions={getItemsFromOptions} - inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles} - containerStyles={darkMode - ? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark - : SELECT_INPUT_DEFAULT_STYLE.containerStyles} - /> - {/key} -
- {#if error} -
error: {JSON.stringify(error)}
+ -
- -
- {#if loading} - - {/if} - - {#if showClear} - - {/if} - - {#if showChevron} - - {/if} -
- - - - - - {#if required && (!value || value.length === 0)} - - - - {/if} - - - diff --git a/frontend/src/lib/components/apps/svelte-select/lib/index.js b/frontend/src/lib/components/apps/svelte-select/lib/index.js deleted file mode 100644 index 80d0787c5f..0000000000 --- a/frontend/src/lib/components/apps/svelte-select/lib/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as default } from './SelectLegacy.svelte' diff --git a/frontend/src/lib/components/select/MultiSelect.svelte b/frontend/src/lib/components/select/MultiSelect.svelte index 171528078e..e94a3cb72f 100644 --- a/frontend/src/lib/components/select/MultiSelect.svelte +++ b/frontend/src/lib/components/select/MultiSelect.svelte @@ -74,7 +74,7 @@ }) let valueEntry = $derived( - value.map((v) => processedItems.find((item) => item.value === v)!).filter(Boolean) + value.map((v) => processedItems.find((item) => item.value === v) ?? { value: v, label: v }) ) function onAddValue(item: ProcessedItem) { diff --git a/frontend/src/lib/components/select/Select.svelte b/frontend/src/lib/components/select/Select.svelte index ec0a4dcc47..69fb7bf16c 100644 --- a/frontend/src/lib/components/select/Select.svelte +++ b/frontend/src/lib/components/select/Select.svelte @@ -4,7 +4,7 @@ import CloseButton from '../common/CloseButton.svelte' import { Loader2 } from 'lucide-svelte' import { untrack } from 'svelte' - import { processItems, type ProcessedItem } from './utils.svelte' + import { getLabel, processItems, type ProcessedItem } from './utils.svelte' import SelectDropdown from './SelectDropdown.svelte' import { deepEqual } from 'fast-equals' @@ -14,7 +14,7 @@ items, placeholder = 'Please select', value = $bindable(), - filterText: _filterTextBind = $bindable(undefined), + filterText = $bindable(''), class: className = '', clearable = false, listAutoWidth = true, @@ -27,6 +27,7 @@ RightIcon, createText, noItemsMsg, + open = $bindable(false), groupBy, sortBy, onFocus, @@ -50,6 +51,7 @@ RightIcon?: any createText?: string noItemsMsg?: string + open?: boolean groupBy?: (item: Item) => string sortBy?: (a: Item, b: Item) => number onFocus?: () => void @@ -60,8 +62,6 @@ let disabled = $derived(_disabled || loading) - let filterText = $state('') - let open = $state(false) let inputEl: HTMLInputElement | undefined = $state() let processedItems: ProcessedItem[] = $derived.by(() => { @@ -69,13 +69,6 @@ return untrack(() => processItems(args)) }) - $effect(() => { - if (_filterTextBind !== undefined) filterText = _filterTextBind - }) - $effect(() => { - if (_filterTextBind !== undefined) _filterTextBind = filterText - }) - $effect(() => { if (filterText) open = true }) @@ -128,12 +121,12 @@ {disabled} type="text" bind:value={() => filterText, (v) => (filterText = v)} - placeholder={loading ? 'Loading...' : (valueEntry?.label ?? placeholder)} + placeholder={loading ? 'Loading...' : (valueEntry?.label ?? getLabel({ value }) ?? placeholder)} style={containerStyle} class={twMerge( '!bg-surface text-ellipsis', open ? '' : 'cursor-pointer', - valueEntry && !loading ? '!placeholder-primary' : '', + value && !loading ? '!placeholder-primary' : '', (clearable || RightIcon) && !disabled && value ? '!pr-8' : '', inputClass ?? '' )} diff --git a/frontend/src/lib/components/select/SelectDropdown.svelte b/frontend/src/lib/components/select/SelectDropdown.svelte index 7780828a70..a589bc22ff 100644 --- a/frontend/src/lib/components/select/SelectDropdown.svelte +++ b/frontend/src/lib/components/select/SelectDropdown.svelte @@ -137,7 +137,7 @@ onSelectValue(item) }} > - {item.label} + {item.label || '\xa0'} {/each} diff --git a/frontend/src/lib/components/wizards/AppPicker.svelte b/frontend/src/lib/components/wizards/AppPicker.svelte index 6dd15edf18..b0369d3c96 100644 --- a/frontend/src/lib/components/wizards/AppPicker.svelte +++ b/frontend/src/lib/components/wizards/AppPicker.svelte @@ -1,16 +1,14 @@
- { - value = e.detail.value - }} - on:clear={() => { - value = '' - }} - bind:value={selecteValue} - items={apps.map((app) => { - return { - value: app.path, - label: app.path === $appPath ? `${app.path} (current app)` : app.path - } - })} + - -
- {#if !$enterpriseLicense} -
- Agent workers are only available in the enterprise edition. For evaluation purposes, you can - only use the tag `agent_test` tag and it is limited to 100 jobs. -
- {/if} - -
+ + Create + Blacklist + {#snippet content()} +
+ {#if selectedTab === 'create'} + Use HTTP agent workers only when the workers need to be deployed remotely OR with only + HTTP connectivity OR in untrusted environments. HTTP agent workers have more latency and + less capabilities than normal workers. +
+
+ +
+
+ {#if !$enterpriseLicense} +
+ Agent workers are only available in the enterprise edition. For evaluation purposes, + you can only use the tag `agent_test` tag and it is limited to 100 jobs. +
+ {/if} + +
-
- {#if !$enterpriseLicense} -
- Agent workers are only available in the enterprise edition. For evaluation purposes, you can - only use the tag `agent_test` tag and it is limited to 100 jobs. -
- {/if} -
- - { - if (token) { - navigator.clipboard.writeText(token) - sendUserToast('Copied to clipboard') - } - }} - placeholder="Select tags to generate a JWT token" - type="text" - disabled - value={token} - class="w-full pr-10 pl-3 py-2 text-sm text-gray-600 bg-gray-50 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-100 transition truncate" - /> +
+ {#if !$enterpriseLicense} +
+ Agent workers are only available in the enterprise edition. For evaluation purposes, + you can only use the tag `agent_test` tag and it is limited to 100 jobs. +
+ {/if} +
+ { + e.preventDefault() + e.stopPropagation() + if (token) { + navigator.clipboard.writeText(token) + sendUserToast('Copied to clipboard') + } + }} + placeholder="Select tags to generate a JWT token" + type="text" + disabled + value={token} + class="w-full pr-10 pl-3 py-2 text-sm text-gray-600 bg-gray-50 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-100 transition truncatere" + /> - - -
+ +
-
- Set the following environment variables: -
    -
  • MODE=agent
  • -
  • AGENT_TOKEN=<token>
  • -
  • BASE_INTERNAL_URL=<base url>
  • -
-

- to a worker to have it act as an HTTP agent worker. - INIT_SCRIPT, if needed, must be passed as an env variable. -

- - Ensure at least one normal worker is running and listening to the tags - flow and dependency - (or flow-<workspace> and dependency-<workspace> if - using workspace-specific default tags), because agent workers - cannot run dependency jobs - nor execute the - flow state machine. They can, however, run subjobs within flows. - - -
- Use the following API endpoint with a superadmin bearer token: - POST /api/agent_workers/create_agent_token -
+						
+ Set the following environment variables: +
    +
  • MODE=agent
  • +
  • AGENT_TOKEN=<token>
  • +
  • BASE_INTERNAL_URL=<base url>
  • +
+

+ to a worker to have it act as an HTTP agent worker. + INIT_SCRIPT, if needed, must be passed as an env variable. +

+ + Ensure at least one normal worker is running and listening to the tags + flow and dependency + (or flow-<workspace> and + dependency-<workspace> + if using workspace-specific default tags), because agent workers + cannot run dependency jobs + nor execute the + flow state machine. They can, however, run subjobs within flows. + + +
+ Use the following API endpoint with a superadmin bearer token: + POST /api/agent_workers/create_agent_token +
 	{`
+											>{`
 	  "worker_group": "agent",
 	  "tags": ["tag1", "tag2"],
 	  "exp": 1717334400
 	`}
-					
- The JSON response will contain the generated JWT token. + > +
+ The JSON response will contain the generated JWT token. +
+
+
+
- + {:else if selectedTab === 'blacklist'} +
+
+ {#if !$enterpriseLicense} +
+ Token blacklist management is only available in the enterprise edition. +
+ {:else} +
+ Add tokens to the blacklist to prevent them from being used by agent workers. + Blacklisted tokens may take up to 5 minutes to be effective because of caching. +
+ +
+
+ + +
+
+ +
+ + {#if !$superadmin} +
+ Only superadmins can manage the token blacklist. +
+ {/if} +
+ + +
+
+

Blacklisted Tokens

+ +
+ + {#if isLoadingBlacklist} +
Loading blacklisted tokens...
+ {:else if blacklistedTokens?.length === 0} +
+ No tokens are currently blacklisted. +
+ {:else} +
+ {#each blacklistedTokens ?? [] as blacklistedToken} +
+
+
+ {blacklistedToken.token} +
+ {#if blacklistedToken.expires_at} +
+ Expires: {new Date(blacklistedToken.expires_at).toLocaleString()} +
+ {/if} +
+ {#if $superadmin} + + {/if} +
+ {/each} +
+ {/if} +
+ {/if} +
+
+ {/if}
- -
+ {/snippet} + From 934ae4fe5740ceb83f234b658f1bfae010f411af Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 20 Jun 2025 12:12:07 +0200 Subject: [PATCH 05/25] rm WM_OIDC_JWT from ctx vars --- frontend/src/lib/deno_fetch.d.ts.txt | 1 - frontend/src/lib/process.d.ts.txt | 1 - 2 files changed, 2 deletions(-) diff --git a/frontend/src/lib/deno_fetch.d.ts.txt b/frontend/src/lib/deno_fetch.d.ts.txt index d61301f4bb..b4586ca93b 100644 --- a/frontend/src/lib/deno_fetch.d.ts.txt +++ b/frontend/src/lib/deno_fetch.d.ts.txt @@ -438,7 +438,6 @@ interface Process { WM_STATE_PATH: string; WM_FLOW_STEP_ID: string; WM_OBJECT_PATH: string; - WM_OIDC_JWT: string; WM_WORKER_GROUP: string; } } \ No newline at end of file diff --git a/frontend/src/lib/process.d.ts.txt b/frontend/src/lib/process.d.ts.txt index 74a58de0c8..e80b2f13ae 100644 --- a/frontend/src/lib/process.d.ts.txt +++ b/frontend/src/lib/process.d.ts.txt @@ -174,7 +174,6 @@ declare module 'process' { WM_STATE_PATH: string WM_FLOW_STEP_ID: string WM_OBJECT_PATH: string - WM_OIDC_JWT: string WM_WORKER_GROUP: string } interface HRTime { From b3b6c534307ceef451e1596c7336db80435f0e5f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 20 Jun 2025 12:12:38 +0200 Subject: [PATCH 06/25] remove WM_OIDC_JWT from context vars --- backend/windmill-api/src/resources.rs | 1 - backend/windmill-api/src/variables.rs | 3 +-- backend/windmill-common/src/variables.rs | 7 ------- backend/windmill-worker/src/bun_executor.rs | 1 - backend/windmill-worker/src/common.rs | 1 - backend/windmill-worker/src/deno_executor.rs | 4 +--- backend/windmill-worker/src/python_executor.rs | 2 -- 7 files changed, 2 insertions(+), 17 deletions(-) diff --git a/backend/windmill-api/src/resources.rs b/backend/windmill-api/src/resources.rs index 14fdfef536..19fd422805 100644 --- a/backend/windmill-api/src/resources.rs +++ b/backend/windmill-api/src/resources.rs @@ -583,7 +583,6 @@ pub async fn transform_json_value<'c>( job.schedule_path.clone(), job.flow_step_id.clone(), job.root_job.map(|x| x.to_string()), - None, Some(job.scheduled_for.clone()), ) .await; diff --git a/backend/windmill-api/src/variables.rs b/backend/windmill-api/src/variables.rs index 4b6601e1e6..c1192feb3b 100644 --- a/backend/windmill-api/src/variables.rs +++ b/backend/windmill-api/src/variables.rs @@ -74,8 +74,7 @@ async fn list_contextual_variables( Some("u/user/triggering_flow_path".to_string()), Some("c".to_string()), Some("017e0ad5-f499-73b6-5488-92a61c5196dd".to_string()), - Some("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c".to_string()), - Some(chrono::offset::Utc::now()) + Some(chrono::offset::Utc::now()), ) .await .to_vec(), diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 3fd2e0513b..1dfa1438d8 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -180,7 +180,6 @@ pub async fn get_reserved_variables( schedule_path: Option, step_id: Option, root_flow_id: Option, - jwt_token: Option, scheduled_for: Option>, ) -> Vec { let state_path = { @@ -334,12 +333,6 @@ pub async fn get_reserved_variables( description: "Script or flow step execution unique path, useful for storing results in an external service".to_string(), is_custom: false, }, - ContextualVariable { - name: "WM_OIDC_JWT".to_string(), - value: jwt_token.unwrap_or_else(|| "".to_string()), - description: "OIDC JWT token (EE only)".to_string(), - is_custom: false, - }, ContextualVariable { name: "WM_WORKER_GROUP".to_string(), value: WORKER_GROUP.clone(), diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index fa64832ee4..cbe389403b 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1566,7 +1566,6 @@ pub async fn start_worker( None, None, None, - None, ) .await; let context_envs = build_envs_map(context.to_vec()).await; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index b419626845..3b5312c14c 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -434,7 +434,6 @@ pub async fn get_reserved_variables( job.schedule_path(), job.flow_step_id.clone(), job.flow_innermost_root_job.clone().map(|x| x.to_string()), - None, Some(job.scheduled_for.clone()), ) .await diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index 2215c94d8d..e9db387b03 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -11,8 +11,7 @@ use crate::{ start_child_process, OccupancyMetrics, }, handle_child::handle_child, - DENO_CACHE_DIR, DENO_PATH, DISABLE_NSJAIL, HOME_ENV, NPM_CONFIG_REGISTRY, - PATH_ENV, TZ_ENV, + DENO_CACHE_DIR, DENO_PATH, DISABLE_NSJAIL, HOME_ENV, NPM_CONFIG_REGISTRY, PATH_ENV, TZ_ENV, }; use windmill_common::client::AuthedClient; @@ -534,7 +533,6 @@ pub async fn start_worker( None, None, None, - None, ) .await; let context_envs = build_envs_map(context.to_vec()).await; diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 90ef0afccd..90be4e9cfc 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -2077,7 +2077,6 @@ pub async fn start_worker( None, None, None, - None, ) .await .to_vec(); @@ -2197,7 +2196,6 @@ for line in sys.stdin: None, None, None, - None, ) .await; From 3e822823519d1d5c22e422e4bd1ad4d37b6428b6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:31:52 +0200 Subject: [PATCH 07/25] feat: add typescript client context to ai chat system prompt (#6004) * feat: add typescript client context to ai chat system prompt Add windmill client function signatures and descriptions to the TypeScript language context in the AI chat system. This provides users with inline documentation for key windmill client functions including resource operations, state management, variables, script execution, S3 operations, and flow utilities. The context is kept concise to avoid making the system prompt too large while still providing the most useful function signatures for TypeScript scripts. Resolves #6002 Co-authored-by: Ruben Fiszel * fix import * feat: add python client context to ai chat system prompt Add PYTHON_WINDMILL_CLIENT_CONTEXT with function signatures and descriptions for key windmill client functions in Python, including: - Resource operations (get_resource, set_resource) - State management (get_state, set_state, get_flow_user_state, set_flow_user_state) - Variables (get_variable, set_variable) - Script execution (run_script, run_script_async, wait_job) - S3 file operations (load_s3_file, write_s3_file) - Flow operations (run_flow_async, get_resume_urls) - Utilities (whoami, get_job_status, set_progress) The context is now included for Python language (python3) in the AI chat system prompt, providing users with helpful function signatures and descriptions when asking for coding assistance. Co-authored-by: centdix --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel Co-authored-by: centdix Co-authored-by: centdix --- .../components/copilot/chat/script/core.ts | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index d26210f7c7..72af4d8b8a 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -61,6 +61,76 @@ You should only use them if you need them to satisfy the user's instructions. Al const TS_INLINE_TYPE_INSTRUCTION = `When using resource types, you should use RT.ResourceType as parameter type. For other parameters, you should inline the objects types instead of defining them separately. This is because Windmill requires the types (other than resource types) to be inlined to generate a user friendly UI from the parameters.` +const TS_WINDMILL_CLIENT_CONTEXT = ` + +The windmill client (wmill) can be used to interact with Windmill from the script. Import it with \`import * as wmill from "windmill-client"\`. Key functions include: + +// Resource operations +wmill.getResource(path?: string): Promise // Get resource value by path +wmill.setResource(value: any, path?: string): Promise // Set resource value + +// State management (persistent across executions) +wmill.getState(): Promise // Get shared state +wmill.setState(state: any): Promise // Set shared state + +// Variables +wmill.getVariable(path: string): Promise // Get variable value +wmill.setVariable(path: string, value: string): Promise // Set variable value + +// Script execution +wmill.runScript(path: string, args?: Record): Promise // Run script synchronously +wmill.runScriptAsync(path: string, args?: Record): Promise // Run script async, returns job ID +wmill.waitJob(jobId: string): Promise // Wait for job completion and get result + +// S3 file operations (if S3 is configured) +wmill.loadS3File(s3object: S3Object): Promise // Load file content from S3 +wmill.writeS3File(s3object: S3Object, content: string | Blob): Promise // Write file to S3 + +// Flow operations +wmill.setFlowUserState(key: string, value: any): Promise // Set flow user state +wmill.getFlowUserState(key: string): Promise // Get flow user state +wmill.getResumeUrls(): Promise<{approvalPage: string, resume: string, cancel: string}> // Get approval URLs + +// Utilities +wmill.getWorkspace(): string // Get current workspace +wmill.databaseUrlFromResource(path: string): Promise // Get database URL from resource` + +const PYTHON_WINDMILL_CLIENT_CONTEXT = ` + +The windmill client (wmill) can be used to interact with Windmill from the script. Import it with \`import wmill\`. Key functions include: + +// Resource operations +wmill.get_resource(path: str) -> dict | None // Get resource value by path +wmill.set_resource(path: str, value: Any, resource_type: str = "any") -> None // Set resource value + +// State management (persistent across executions) +wmill.get_state() -> Any // Get shared state (deprecated, use flow user state) +wmill.set_state(value: Any) -> None // Set shared state +wmill.get_flow_user_state(key: str) -> Any // Get flow user state +wmill.set_flow_user_state(key: str, value: Any) -> None // Set flow user state + +// Variables +wmill.get_variable(path: str) -> str // Get variable value +wmill.set_variable(path: str, value: str, is_secret: bool = False) -> None // Set variable value + +// Script execution +wmill.run_script(path: str, args: dict = None, timeout = None) -> Any // Run script synchronously +wmill.run_script_async(path: str, args: dict = None, scheduled_in_secs: int = None) -> str // Run script async, returns job ID +wmill.wait_job(job_id: str, timeout = None) -> Any // Wait for job completion and get result + +// S3 file operations (if S3 is configured) +wmill.load_s3_file(s3object: S3Object, s3_resource_path: str = None) -> bytes // Load file content from S3 +wmill.write_s3_file(s3object: S3Object, file_content: bytes, s3_resource_path: str = None) -> S3Object // Write file to S3 + +// Flow operations +wmill.run_flow_async(path: str, args: dict = None) -> str // Run flow asynchronously +wmill.get_resume_urls(approver: str = None) -> dict // Get approval URLs for flow steps + +// Utilities +wmill.whoami() -> dict // Get current user information +wmill.get_job_status(job_id: str) -> str // Get job status ("RUNNING" | "WAITING" | "COMPLETED") +wmill.set_progress(value: int) -> None // Set job progress (0-100)` + const PYTHON_RESOURCE_TYPE_SYSTEM = `On Windmill, credentials and configuration are stored in resources and passed as parameters to main. If you need credentials, you should add a parameter to \`main\` with the corresponding resource type. You need to **redefine** the type of the resources that are needed before the main function as TypedDict, but only include them if they are actually needed to achieve the function purpose. @@ -124,7 +194,9 @@ export function getLangContext( : TS_RESOURCE_TYPE_SYSTEM + (allowResourcesFetch ? `To query the RT namespace, you can use the \`search_resource_types\` tool.\n` - : '')) + TS_INLINE_TYPE_INSTRUCTION + : '')) + + TS_INLINE_TYPE_INSTRUCTION + + TS_WINDMILL_CLIENT_CONTEXT const mainFunctionName = isPreprocessor ? 'preprocessor' : 'main' @@ -152,7 +224,8 @@ export function getLangContext( (isPreprocessor ? PYTHON_PREPROCESSOR_INSTRUCTION : PYTHON_RESOURCE_TYPE_SYSTEM + - `${allowResourcesFetch ? `\nTo query the available resource types, you can use the \`search_resource_types\` tool.` : ''}`) + `${allowResourcesFetch ? `\nTo query the available resource types, you can use the \`search_resource_types\` tool.` : ''}`) + + PYTHON_WINDMILL_CLIENT_CONTEXT ) case 'php': return ( From 88ab1a513605722c1ea9737279303127ff4c5942 Mon Sep 17 00:00:00 2001 From: pyranota <92104930+pyranota@users.noreply.github.com> Date: Fri, 20 Jun 2025 13:24:27 +0200 Subject: [PATCH 08/25] Use regex to split input to python import parser (#6007) --- .../parsers/windmill-parser-py-imports/src/lib.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/parsers/windmill-parser-py-imports/src/lib.rs b/backend/parsers/windmill-parser-py-imports/src/lib.rs index 4e51be6b6a..3e84a4f7a5 100644 --- a/backend/parsers/windmill-parser-py-imports/src/lib.rs +++ b/backend/parsers/windmill-parser-py-imports/src/lib.rs @@ -30,8 +30,6 @@ use windmill_common::{ worker::PythonAnnotations, }; -const DEF_MAIN: &str = "def main("; - fn replace_import(x: String) -> String { SHORT_IMPORTS_MAP .get(&x) @@ -48,6 +46,9 @@ lazy_static! { static ref RE: Regex = Regex::new(r"^\#\s?(\S+)\s*$").unwrap(); static ref PIN_RE: Regex = Regex::new(r"(?:\s*#\s*(pin|repin):\s*)(\S*)").unwrap(); static ref PKG_RE: Regex = Regex::new(r"^([^!=<>]+)(?:[!=<>]|$)").unwrap(); + // Regex to properly match main function definition at line start, + // capturing both sync and async variants + static ref DEF_MAIN_RE: Regex = Regex::new(r"(?m)^(async\s+)?def\s+main\s*\(").unwrap(); } fn process_import(module: Option, path: &str, level: usize) -> Vec { @@ -143,7 +144,12 @@ struct ImportPin { } fn parse_code_for_imports(code: &str, path: &str) -> error::Result> { - let mut code = code.split(DEF_MAIN).next().unwrap_or("").to_string(); + // Use regex to safely find the main function definition + let mut code = DEF_MAIN_RE + .split(code) + .next() + .unwrap_or_default() + .to_string(); // remove main function decorator from end of file if it exists if code From af6b724f0bca85cc62411ec7ce93b9c4b0cc38b0 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 20 Jun 2025 13:32:33 +0200 Subject: [PATCH 09/25] internal: use git app for git actions (#6010) * add flow to ask claude to review pr * allow app for claude * cleaning * use app for change versions flow * add owner * fix * rename --- .github/workflows/change-versions.yml | 12 +++++ .github/workflows/check-org-membership.yml | 60 ++++++++++++++++++++++ .github/workflows/claude.yml | 52 ++++++++----------- .github/workflows/create-docs.yml | 44 ++++++++-------- .github/workflows/helmchart_on_release.yml | 12 ++++- .github/workflows/pr-ready-review.yml | 29 +++++++++++ 6 files changed, 152 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/check-org-membership.yml create mode 100644 .github/workflows/pr-ready-review.yml diff --git a/.github/workflows/change-versions.yml b/.github/workflows/change-versions.yml index 034b022f06..eea62bf6b7 100644 --- a/.github/workflows/change-versions.yml +++ b/.github/workflows/change-versions.yml @@ -9,7 +9,14 @@ jobs: runs-on: ubicloud container: node:18 steps: + - uses: actions/create-github-app-token@v2 + id: app + with: + app-id: ${{ vars.INTERNAL_APP_ID }} + private-key: ${{ secrets.INTERNAL_APP_KEY }} - uses: actions/checkout@v4 + with: + token: ${{ steps.app.outputs.token }} - run: git config --system --add safe.directory /__w/windmill/windmill - name: Change versions run: ./.github/change-versions.sh "$(cat version.txt)" @@ -21,3 +28,8 @@ jobs: cd backend cargo generate-lockfile - uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_user_name: windmill-internal-app[bot] + commit_user_email: windmill-internal-app[bot]@users.noreply.github.com + env: + GITHUB_TOKEN: ${{ steps.app.outputs.token }} diff --git a/.github/workflows/check-org-membership.yml b/.github/workflows/check-org-membership.yml new file mode 100644 index 0000000000..910c39c7ba --- /dev/null +++ b/.github/workflows/check-org-membership.yml @@ -0,0 +1,60 @@ +name: Check Organization Membership + +on: + workflow_call: + inputs: + commenter: + required: true + type: string + description: 'The username to check for organization membership' + organization: + required: false + type: string + default: 'windmill-labs' + description: 'The organization to check membership for' + trusted_bot: + required: false + type: string + default: 'windmill-internal-app[bot]' + description: 'The trusted bot username to allow' + secrets: + access_token: + required: true + description: 'The access token to use for org membership check' + outputs: + is_member: + description: 'Whether the user is an organization member or trusted bot' + value: ${{ jobs.check-membership.outputs.is_member }} + +jobs: + check-membership: + runs-on: ubicloud-standard-2 + outputs: + is_member: ${{ steps.check-membership.outputs.is_member }} + steps: + - name: Check organization membership + id: check-membership + env: + ORG_ACCESS_TOKEN: ${{ secrets.access_token }} + COMMENTER: ${{ inputs.commenter }} + ORG: ${{ inputs.organization }} + TRUSTED_BOT: ${{ inputs.trusted_bot }} + run: | + # 1. Allow the trusted bot straight away + if [[ "$COMMENTER" == "$TRUSTED_BOT" ]]; then + echo "is_member=true" >> $GITHUB_OUTPUT + exit 0 + fi + + # 2. Otherwise fall back to the org-membership check + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token $ORG_ACCESS_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/orgs/$ORG/members/$COMMENTER") + + if [ "$STATUS" -eq 204 ]; then + echo "is_member=true" >> $GITHUB_OUTPUT + else + echo "is_member=false" >> $GITHUB_OUTPUT + fi \ No newline at end of file diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 26420fd6c1..0c5d6d59d4 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -11,45 +11,40 @@ on: types: [submitted] jobs: - check-membership: + determine-commenter: if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai') && !contains(github.event.comment.user.login, '[bot]')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai') && !contains(github.event.comment.user.login, '[bot]')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '/ai') && !contains(github.event.review.user.login, '[bot]')) || - (github.event_name == 'issues' && contains(github.event.issue.body, '/ai') && !contains(github.event.issue.user.login, '[bot]')) + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '/ai')) || + (github.event_name == 'issues' && contains(github.event.issue.body, '/ai')) runs-on: ubicloud-standard-2 outputs: - is_member: ${{ steps.check-membership.outputs.is_member }} + commenter: ${{ steps.determine-commenter.outputs.commenter }} steps: - - name: Check organization membership - id: check-membership - env: - ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }} + - name: Determine commenter + id: determine-commenter run: | - ORG="windmill-labs" - - if [[ "${{ github.event_name }}" == "issue_comment" || "${{ github.event_name }}" == "pull_request_review_comment" ]]; then + # Work out who wrote the comment / review + if [[ "${{ github.event_name }}" == "issue_comment" || \ + "${{ github.event_name }}" == "pull_request_review_comment" ]]; then COMMENTER="${{ github.event.comment.user.login }}" elif [[ "${{ github.event_name }}" == "pull_request_review" ]]; then COMMENTER="${{ github.event.review.user.login }}" else COMMENTER="${{ github.event.issue.user.login }}" fi + echo "commenter=$COMMENTER" >> $GITHUB_OUTPUT - STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "Authorization: token $ORG_ACCESS_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/orgs/$ORG/members/$COMMENTER") - - if [ "$STATUS" -eq 204 ]; then - echo "is_member=true" >> $GITHUB_OUTPUT - else - echo "is_member=false" >> $GITHUB_OUTPUT - fi + check-membership: + needs: determine-commenter + uses: ./.github/workflows/check-org-membership.yml + with: + commenter: ${{ needs.determine-commenter.outputs.commenter }} + secrets: + access_token: ${{ secrets.ORG_ACCESS_TOKEN }} claude-code-action: - needs: check-membership + needs: [determine-commenter, check-membership] if: | needs.check-membership.outputs.is_member == 'true' runs-on: ubicloud-standard-8 @@ -64,13 +59,6 @@ jobs: with: fetch-depth: 1 - # Cache rust dependencies - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - workspaces: "./backend -> target" - - # Cache node dependencies - uses: actions/cache@v3 with: path: ~/.npm diff --git a/.github/workflows/create-docs.yml b/.github/workflows/create-docs.yml index 2d264f9755..e6b313617c 100644 --- a/.github/workflows/create-docs.yml +++ b/.github/workflows/create-docs.yml @@ -4,38 +4,36 @@ on: jobs: check-membership: - if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && github.event.comment.user.type != 'Bot' }} + if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') }} + uses: ./.github/workflows/check-org-membership.yml + with: + commenter: ${{ github.event.comment.user.login }} + secrets: + access_token: ${{ secrets.ORG_ACCESS_TOKEN }} + + generate-token: + needs: check-membership + if: ${{ needs.check-membership.outputs.is_member == 'true' }} runs-on: ubicloud-standard-2 outputs: - is_member: ${{ steps.check-membership.outputs.is_member }} + app_token: ${{ steps.app.outputs.token }} steps: - - name: Check organization membership - id: check-membership - env: - ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }} - COMMENTER: ${{ github.event.comment.user.login }} - run: | - ORG="windmill-labs" - STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "Authorization: token $ORG_ACCESS_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/orgs/$ORG/members/$COMMENTER") - - if [ "$STATUS" -eq 204 ]; then - echo "is_member=true" >> $GITHUB_OUTPUT - else - echo "is_member=false" >> $GITHUB_OUTPUT - fi + - name: Generate an installation token + id: app + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.INTERNAL_APP_ID }} + private-key: ${{ secrets.INTERNAL_APP_KEY }} + owner: windmill-labs trigger-docs: - needs: check-membership - if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && needs.check-membership.outputs.is_member == 'true' }} + needs: [generate-token, check-membership] + if: ${{ needs.check-membership.outputs.is_member == 'true' }} uses: windmill-labs/windmilldocs/.github/workflows/create-docs.yml@main with: pr_number: ${{ github.event.issue.number }} repo: ${{ github.event.repository.name }} comment_text: ${{ github.event.comment.body }} secrets: - DOCS_TOKEN: ${{ secrets.DOCS_TOKEN }} + DOCS_TOKEN: ${{ needs.generate-token.outputs.app_token }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} diff --git a/.github/workflows/helmchart_on_release.yml b/.github/workflows/helmchart_on_release.yml index eeae218d5d..b3bc4e8c2f 100644 --- a/.github/workflows/helmchart_on_release.yml +++ b/.github/workflows/helmchart_on_release.yml @@ -9,11 +9,19 @@ jobs: runs-on: ubicloud-standard-2 steps: + - name: Generate an installation token + id: app + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.INTERNAL_APP_ID }} + private-key: ${{ secrets.INTERNAL_APP_KEY }} + owner: windmill-labs + - name: Checkout on helm repository uses: actions/checkout@v3 with: repository: windmill-labs/windmill-helm-charts - token: ${{ secrets.HELM_CHART_TOKEN }} + token: ${{ steps.app.outputs.token }} - name: Get version id: get_version @@ -57,7 +65,7 @@ jobs: - name: Create PR env: - GH_TOKEN: ${{ secrets.HELM_CHART_TOKEN }} + GH_TOKEN: ${{ steps.app.outputs.token }} run: | gh pr create \ --title "helm: bump version to ${{ env.VERSION }}" \ diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml new file mode 100644 index 0000000000..f2e06aebd1 --- /dev/null +++ b/.github/workflows/pr-ready-review.yml @@ -0,0 +1,29 @@ +name: Auto Comment on PR Ready for Review + +on: + pull_request: + types: [ready_for_review] + +jobs: + add-review-comment: + if: github.event.pull_request.draft == false + runs-on: ubicloud-standard-2 + steps: + - name: Generate an installation token + id: app + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.INTERNAL_APP_ID }} + private-key: ${{ secrets.INTERNAL_APP_KEY }} + + - name: Add review comment + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app.outputs.token }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: '/ai review this PR' + }); From 99e18aedeacb2a39e780ad961f405ebab182fe8d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 20 Jun 2025 14:08:42 +0200 Subject: [PATCH 10/25] nit typo --- backend/windmill-api/src/apps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index befe569229..6e127ebc60 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -2011,7 +2011,7 @@ async fn upload_s3_file_from_app( if !has_unnamed_policy { return Err(Error::BadRequest( - "no policy found for unnamed s3 file uplooad".to_string(), + "no policy found for unnamed s3 file upload".to_string(), )); } From f3ecbe1792034de950ac66e233be5a91beb0a802 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Fri, 20 Jun 2025 13:35:34 +0100 Subject: [PATCH 11/25] runs filter popover close on click outside (#6013) --- frontend/src/lib/components/runs/RunsFilter.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/components/runs/RunsFilter.svelte b/frontend/src/lib/components/runs/RunsFilter.svelte index 77ab068bfe..50223c9c45 100644 --- a/frontend/src/lib/components/runs/RunsFilter.svelte +++ b/frontend/src/lib/components/runs/RunsFilter.svelte @@ -522,6 +522,7 @@ floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }} contentClasses="p-4" closeButton + usePointerDownOutside > {#snippet trigger()} {/if} diff --git a/frontend/src/lib/components/ModulePreview.svelte b/frontend/src/lib/components/ModulePreview.svelte index ffa1530fcf..93ea296ec6 100644 --- a/frontend/src/lib/components/ModulePreview.svelte +++ b/frontend/src/lib/components/ModulePreview.svelte @@ -1,22 +1,13 @@ - jobDone()} +
@@ -130,7 +57,7 @@
{#if testIsLoading} - @@ -139,7 +66,7 @@ color="dark" btnClasses="truncate" size="sm" - on:click={() => runTest(stepArgs)} + on:click={runTestWithStepArgs} shortCut={{ Icon: CornerDownLeft }} @@ -149,5 +76,5 @@ {/if}
- +
diff --git a/frontend/src/lib/components/ModulePreviewForm.svelte b/frontend/src/lib/components/ModulePreviewForm.svelte index 1825d82252..926c5ccff3 100644 --- a/frontend/src/lib/components/ModulePreviewForm.svelte +++ b/frontend/src/lib/components/ModulePreviewForm.svelte @@ -5,77 +5,91 @@ import { RefreshCw } from 'lucide-svelte' import ArgInput from './ArgInput.svelte' import { Button } from './common' - import { getContext, untrack } from 'svelte' + import { getContext, onMount, untrack } from 'svelte' import type { FlowEditorContext } from './flows/types' import { evalValue } from './flows/utils' import type { FlowModule } from '$lib/gen' import type { PickableProperties } from './flows/previousResults' import type SimpleEditor from './SimpleEditor.svelte' import { getResourceTypes } from './resourceTypesStore' + import { twMerge } from 'tailwind-merge' interface Props { schema: Schema | { properties?: Record; required?: string[] } - args?: Record mod: FlowModule pickableProperties: PickableProperties | undefined isValid?: boolean autofocus?: boolean + focusArg?: string } let { schema, - args = $bindable({}), mod, pickableProperties, isValid = $bindable(true), - autofocus = false + autofocus = false, + focusArg = undefined }: Props = $props() - const { testStepStore } = getContext('FlowEditorContext') + const { testSteps, flowStateStore, flowStore, previewArgs } = + getContext('FlowEditorContext') let inputCheck: { [id: string]: boolean } = $state({}) $effect(() => { isValid = allTrue(inputCheck) ?? false }) - $effect(() => { - if (args == undefined || typeof args !== 'object') { - args = {} - } - }) - - function removeExtraKey() { - const nargs = {} - Object.keys(args ?? {}).forEach((key) => { - if (keys.includes(key)) { - nargs[key] = args[key] - } - }) - args = nargs - } - let keys: string[] = $state([]) $effect(() => { let lkeys = Object.keys(schema?.properties ?? {}) if (schema?.properties && JSON.stringify(lkeys) != JSON.stringify(keys)) { keys = lkeys - untrack(() => removeExtraKey()) + untrack(() => testSteps?.removeExtraKey(mod.id, keys)) } }) function plugIt(argName: string) { - args[argName] = structuredClone( - $state.snapshot(evalValue(argName, mod, testStepStore, pickableProperties, true)) + testSteps?.setEvaluatedStepArg( + mod.id, + argName, + $state.snapshot(evalValue(argName, mod, pickableProperties, true)) ) - try { - editor?.[argName]?.setCode(JSON.stringify(args[argName], null, 4)) - } catch { - //ignore - } } let editor: Record = $state({}) + // Animation and highlighting for focusArg + let animateArg: string | undefined = $state(undefined) + $effect(() => { + if (focusArg) { + // Add a slight delay to ensure the form is rendered + setTimeout(() => { + const argElement = document.querySelector(`[data-arg="${focusArg}"]`) + if (argElement) { + // Add highlight animation + animateArg = focusArg + argElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) + + // Focus the input if it exists + const input = argElement.querySelector('input, textarea, select') as + | HTMLInputElement + | HTMLTextAreaElement + | HTMLSelectElement + | null + if (input) { + input.focus() + } + + // Remove highlight after animation + setTimeout(() => { + animateArg = undefined + }, 2000) + } + }, 200) + } + }) + let resourceTypes: string[] | undefined = $state(undefined) async function loadResourceTypes() { @@ -83,21 +97,34 @@ } loadResourceTypes() + + let args = $state(>{}) + + onMount(() => { + testSteps?.updateStepArgs(mod.id, $flowStateStore, flowStore?.val, previewArgs?.val) + args = testSteps?.getStepArgs(mod.id) ?? { value: {} } + }) -
+
{#if keys.length > 0} {#each keys as argName, i (argName)} {#if Object.keys(schema.properties ?? {}).includes(argName)} -
- {#if typeof args == 'object' && schema?.properties?.[argName]} +
+ {#if typeof args.value == 'object' && schema?.properties?.[argName]} {/if} -
- -
+ {#if testSteps?.isArgManuallySet(mod.id, argName)} +
+ +
+ {/if}
{/if} {/each} diff --git a/frontend/src/lib/components/ModulePreviewResultViewer.svelte b/frontend/src/lib/components/ModulePreviewResultViewer.svelte index 70ade6a2a8..ec203979c9 100644 --- a/frontend/src/lib/components/ModulePreviewResultViewer.svelte +++ b/frontend/src/lib/components/ModulePreviewResultViewer.svelte @@ -41,7 +41,7 @@ onUpdateMock }: Props = $props() - const { testStepStore } = getContext('FlowEditorContext') + const { testSteps } = getContext('FlowEditorContext') let selectedJob: Job | undefined = $state(undefined) let fetchingLastJob = false @@ -49,7 +49,7 @@ let jobProgressReset: () => void = $state(() => {}) let nlastJob = $derived.by(() => { - if (testJob) { + if (testJob && testJob.type === 'CompletedJob') { return { ...testJob, preview: true } } if (lastJob) { @@ -90,7 +90,7 @@ {disableHistory} > {#snippet copilot_fix()} - {#if lang && editor && diffEditor && $testStepStore[mod.id] && selectedJob?.type === 'CompletedJob' && !selectedJob.success && getStringError(selectedJob.result)} + {#if lang && editor && diffEditor && testSteps.getStepArgs(mod.id) && selectedJob?.type === 'CompletedJob' && !selectedJob.success && getStringError(selectedJob.result)} {/if} {/snippet} diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte new file mode 100644 index 0000000000..705ef031ca --- /dev/null +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -0,0 +1,137 @@ + + + + + jobDone()} + bind:scriptProgress + bind:this={testJobLoader} + bind:isLoading={ + () => testModulesState[mod.id]?.loading ?? false, + (v) => + (testModulesState[mod.id] = { + ...testModulesState[mod.id], + loading: v ?? false, + instances: testModulesState[mod.id]?.instances ?? 0 + }) + } + bind:job={testJob} +/> diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index e342420233..f230d89798 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -49,6 +49,7 @@ portal?: string } | undefined = undefined + export let dropdownBtnClasses: string = '' type MenuItem = { label: string @@ -309,7 +310,8 @@ 'rounded-md m-0 p-0 center-center h-full', variant === 'border' ? 'border-0 border-r border-y ' : 'border-0', 'rounded-r-md !rounded-l-none', - size === 'xs2' ? '!w-8' : '!w-10' + size === 'xs2' ? '!w-8' : '!w-10', + dropdownBtnClasses )} > diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index 2fcac3854c..d9bce7c83d 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -32,6 +32,10 @@ }) | undefined onDeployTrigger?: (trigger: Trigger) => void + onTestUpTo?: ((id: string) => void) | undefined + onEditInput?: ((moduleId: string, key: string) => void) | undefined + forceTestTab?: Record + highlightArg?: Record } let { @@ -44,7 +48,11 @@ smallErrorHandler = false, newFlow = false, savedFlow = undefined, - onDeployTrigger = () => {} + onDeployTrigger = () => {}, + onTestUpTo = undefined, + onEditInput = undefined, + forceTestTab, + highlightArg }: Props = $props() let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state() @@ -93,6 +101,8 @@ } aiChatManager.generateStep(detail.moduleId, detail.lang, detail.instructions) }} + {onTestUpTo} + {onEditInput} /> {/if}
@@ -113,6 +123,8 @@ on:applyArgs on:testWithArgs {onDeployTrigger} + {forceTestTab} + {highlightArg} /> {/if} diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index 030d0792a1..41b3d15827 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -25,6 +25,8 @@ }) | undefined onDeployTrigger?: (trigger: Trigger) => void + forceTestTab?: Record + highlightArg?: Record } let { @@ -33,7 +35,9 @@ newFlow = false, disabledFlowInputs = false, savedFlow = undefined, - onDeployTrigger = () => {} + onDeployTrigger = () => {}, + forceTestTab, + highlightArg }: Props = $props() const { @@ -138,6 +142,8 @@ previousModule={flowStore.val.value.modules[index - 1]} {enableAi} savedModule={savedFlow?.value.modules[index]} + {forceTestTab} + {highlightArg} /> {/each} {/key} diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index c613b56ed9..70ffe5a3ec 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -77,6 +77,8 @@ noEditor: boolean enableAi: boolean savedModule?: FlowModule | undefined + forceTestTab?: boolean + highlightArg?: string } let { @@ -89,7 +91,9 @@ scriptTemplate = 'script', noEditor, enableAi, - savedModule = undefined + savedModule = undefined, + forceTestTab = false, + highlightArg = undefined }: Props = $props() let tag: string | undefined = $state(undefined) @@ -274,6 +278,20 @@ onDestroy(() => { $currentEditor = undefined }) + + // Handle force test tab prop with animation + $effect(() => { + if (forceTestTab) { + selected = 'test' + // Add a smooth transition to the test tab + setTimeout(() => { + const testTab = document.querySelector('[value="test"]') + if (testTab) { + testTab.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + }, 100) + } + }) @@ -525,6 +543,7 @@ bind:testJob bind:testIsLoading bind:scriptProgress + focusArg={highlightArg} /> {:else if selected === 'advanced'} diff --git a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte index fa3116631e..a61d1dbdb9 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte @@ -40,6 +40,8 @@ parentModule?: FlowModule | undefined // Pointer to previous module, for easy access to testing results previousModule?: FlowModule | undefined + forceTestTab?: Record + highlightArg?: Record } let { @@ -48,7 +50,9 @@ enableAi = false, savedModule = undefined, parentModule = $bindable(), - previousModule = undefined + previousModule = undefined, + forceTestTab, + highlightArg }: Props = $props() function initializePrimaryScheduleForTriggerScript(module: FlowModule) { @@ -196,6 +200,8 @@ {scriptTemplate} {enableAi} {savedModule} + forceTestTab={forceTestTab?.[flowModule.id]} + highlightArg={highlightArg?.[flowModule.id]} /> {/if} {:else if flowModule.value.type === 'forloopflow' || flowModule.value.type == 'whileloopflow'} @@ -210,6 +216,8 @@ ? savedModule.value.modules[index] : undefined} {enableAi} + {forceTestTab} + {highlightArg} /> {/each} {:else if flowModule.value.type === 'branchone'} @@ -229,6 +237,8 @@ ? savedModule.value.default[index] : undefined} {enableAi} + {forceTestTab} + {highlightArg} /> {/each} {/if} @@ -252,6 +262,8 @@ ? savedModule.value.branches[branchIndex]?.modules[index] : undefined} {enableAi} + {forceTestTab} + {highlightArg} /> {/each} {/if} @@ -271,6 +283,8 @@ savedModule={savedModule?.value.type === 'branchall' ? savedModule.value.branches[branchIndex]?.modules[index] : undefined} + {forceTestTab} + {highlightArg} /> {/each} {/if} diff --git a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte index f053c6cb62..c3e80d836f 100644 --- a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte +++ b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte @@ -70,6 +70,12 @@ 'triggers' ].includes($selectedId) || $selectedId?.includes('branch') + + export function testUpTo() { + if (upToDisabled) return + previewMode = 'upTo' + previewOpen = true + } {#if !upToDisabled} @@ -78,10 +84,7 @@ disabled={upToDisabled} color="light" variant="border" - on:click={() => { - previewMode = 'upTo' - previewOpen = !previewOpen - }} + on:click={testUpTo} startIcon={{ icon: Play }} > Test up to  diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index e0f8e3e17b..9b05735105 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -14,7 +14,9 @@ Square, SkipForward, Pin, - X + X, + Play, + Loader2 } from 'lucide-svelte' import { createEventDispatcher, getContext, untrack } from 'svelte' import { fade } from 'svelte/transition' @@ -23,6 +25,7 @@ import { twMerge } from 'tailwind-merge' import IdEditorInput from '$lib/components/IdEditorInput.svelte' import { dfs } from '../dfs' + import { dfs as dfsPreviousResults } from '../previousResults' import { Drawer } from '$lib/components/common' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import { getDependeeAndDependentComponents } from '../flowExplorer' @@ -32,6 +35,8 @@ import OutputPicker from '$lib/components/flows/propPicker/OutputPicker.svelte' import OutputPickerInner from '$lib/components/flows/propPicker/OutputPickerInner.svelte' import type { FlowState } from '$lib/components/flows/flowState' + import { Button } from '$lib/components/common' + import ModuleTest from '$lib/components/ModuleTest.svelte' interface Props { selected?: boolean @@ -63,7 +68,10 @@ alwaysShowOutputPicker?: boolean loopStatus?: { type: 'inside' | 'self'; flow: 'forloopflow' | 'whileloopflow' } | undefined icon?: import('svelte').Snippet + onTestUpTo?: ((id: string) => void) | undefined + inputTransform?: Record | undefined onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void + onEditInput?: (moduleId: string, key: string) => void } let { @@ -91,7 +99,10 @@ alwaysShowOutputPicker = false, loopStatus = undefined, icon, - onUpdateMock + onTestUpTo, + inputTransform, + onUpdateMock, + onEditInput }: Props = $props() let pickableIds: Record | undefined = $state(undefined) @@ -116,11 +127,15 @@ let newId: string = $state(id ?? '') + let moduleTest: ModuleTest | undefined = $state(undefined) + let testIsLoading = $state(false) let hover = $state(false) let connectingData: any | undefined = $state(undefined) let lastJob: any | undefined = $state(undefined) let outputPicker: OutputPicker | undefined = $state(undefined) let historyOpen = $state(false) + let testJob: any | undefined = $state(undefined) + let outputPickerBarOpen = $state(false) let flowStateStore = $derived(flowEditorContext?.flowStateStore) @@ -158,10 +173,24 @@ flowStateStore && $flowStateStore && untrack(() => updateLastJob($flowStateStore)) }) + let nlastJob = $derived.by(() => { + if (testJob) { + return { ...testJob, preview: true } + } + if (lastJob) { + return { ...lastJob, preview: false } + } + return undefined + }) + let isConnectingCandidate = $derived( !!id && !!$flowPropPickerConfig && !!pickableIds && Object.keys(pickableIds).includes(id) ) + const outputPickerVisible = $derived( + editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id + ) + const icon_render = $derived(icon) @@ -223,22 +252,36 @@ {/if} +{#if deletable && id && flowEditorContext?.flowStore && outputPickerVisible} + {@const flowStore = flowEditorContext?.flowStore.val} + {@const mod = flowStore?.value ? dfsPreviousResults(id, flowStore, false)[0] : undefined} + {#if mod && $flowStateStore[id]} + + {/if} +{/if} +
(hover = true)} onmouseleave={() => (hover = false)} onpointerdown={stopPropagation(preventDefault(() => dispatch('pointerdown')))} > -
+
+
{#if retry}
- {#if editMode && (isConnectingCandidate || alwaysShowOutputPicker)} + {#if outputPickerVisible} {#snippet children({ allowCopy, isConnecting, selectConnection })} {/snippet} @@ -389,10 +438,60 @@
{#if deletable} +
+ {#if (hover || selected) && outputPickerVisible} +
+ {#if !testIsLoading} + + {:else} + + {/if} +
+ {/if} +
+ {/if} + + {/if} +{/snippet} + +{#snippet editKey(key: string)} + +{/snippet} diff --git a/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte b/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte index 864602718f..b62326352f 100644 --- a/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte +++ b/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte @@ -1,10 +1,12 @@ - { +
{ e.preventDefault() e.stopPropagation() }} - bind:this={popover} - allowFullScreen - contentClasses="overflow-hidden resize rounded-md" - contentStyle={`width: calc(${MIN_WIDTH}px); min-width: calc(${MIN_WIDTH}px); height: calc(${MIN_HEIGHT}px); min-height: calc(${MIN_HEIGHT}px);`} - extraProps={{ 'data-prop-picker': true }} - closeOnOtherPopoverOpen - class="outline-none" > - {#snippet trigger({ isOpen })} -
{ - e.preventDefault() - e.stopPropagation() - }} - data-prop-picker - title={`${isOpen ? 'Close' : 'Open'} step output`} - > -
- +
+
+
+ {#if showInput} + { + e.preventDefault() + e.stopPropagation() + }} + allowFullScreen + contentClasses="overflow-hidden resize" + contentStyle={`width: calc(${MIN_WIDTH}px); min-width: calc(${MIN_WIDTH}px); height: calc(${MIN_HEIGHT}px); min-height: calc(${MIN_HEIGHT}px); `} + extraProps={{ 'data-prop-picker': true }} + closeOnOtherPopoverOpen + disableFocusTrap + class="flex-1 h-full" + bind:isOpen={inputOpen} + bind:this={inputPopover} > -
+ In + + {/snippet} + {#snippet content()} + + {/snippet} + + {/if} + { + e.preventDefault() + e.stopPropagation() + }} + bind:this={popover} + allowFullScreen + contentClasses="overflow-hidden resize" + contentStyle={`width: calc(${MIN_WIDTH}px); min-width: calc(${MIN_WIDTH}px); height: calc(${MIN_HEIGHT}px); min-height: calc(${MIN_HEIGHT}px); `} + extraProps={{ 'data-prop-picker': true }} + closeOnOtherPopoverOpen + class="flex-1 h-full" + bind:isOpen={outputOpen} + > + {#snippet trigger({ isOpen })} + - -
- -
+ + + {/snippet} + {#snippet content()} + {@render children?.({ + allowCopy: !$flowPropPickerConfig, + isConnecting: showConnecting, + selectConnection + })} + {/snippet} +
- {/snippet} - {#snippet content()} - {@render children?.({ - allowCopy: !$flowPropPickerConfig, - isConnecting: showConnecting, - selectConnection - })} - {/snippet} - +
+