From bf16e7d49a7486d37cf9eb1907e80abae47a78a7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 23 Jul 2026 23:21:24 +0200 Subject: [PATCH] feat: surface workspace-script advanced settings in flow editor (#10289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flow-editor): surface workspace-script advanced settings in flows Workspace-script steps in a flow could not view or edit script-level runtime settings (concurrency, cache, timeout, debounce, dedicated worker, priority, delete-after-use). The concurrency and cache tabs only showed a "set it on the script" warning with no value and no way to act on it. - Add ScriptAdvancedSettings, a reusable subset of the script editor's runtime settings, and two entry points that reuse it: - WorkspaceScriptSettingsDrawer: a mini settings drawer reachable from the flow step (header "Settings" button and the delegating tabs), saving a new script version with the code left unchanged. - an inner "Settings" drawer inside ScriptEditorDrawer, saved together with the code. - Replace the concurrency/cache delegation warnings with a box that fetches the referenced script's current value and offers an "Edit script settings" shortcut (useWorkspaceScriptSettings loader). - Add ScriptSettingsBadges showing active advanced settings, in the standalone script editor top bar, the edit-code drawer, and above the workspace-script step preview. Fixes WIN-2233 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): keep subflow concurrency note distinct from workspace-script The concurrency delegation box is workspace-script specific; subflow steps now keep a plain limitation note instead of the script settings shortcut. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): preserve all script fields when saving settings-only version Building the createScript body by hand dropped codebase/labels/envs and other fields on the new version. Spread the loaded script instead and override only lineage, matching ScriptEditorDrawer's save. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): address review — settings-save safety and stale display - WorkspaceScriptSettingsDrawer: keep settings-only saves from hijacking execution identity or discarding the author's draft (preserve_on_behalf_of + skip_draft_deletion), and normalize cleared concurrency/debounce keys to undefined so blanks don't become shared global keys. - ScriptEditorDrawer: normalize cleared keys in its save too (the inner settings drawer edits them). - FlowModuleComponent: reload the surfaced concurrency/cache values + badges after a header settings/code save; gate settings editing on customUi.scriptEdit. - useWorkspaceScriptSettings: sequence-guard load() against stale overwrites. - Add unit tests for getActiveScriptSettingsBadges. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): round-3 review — concurrency-safe save, load guards, UI gates - WorkspaceScriptSettingsDrawer: drop auto_parent so a settings-only save uses the loaded parent as an optimistic-concurrency guard (fails loudly instead of silently reverting a concurrent deploy); sequence-guard openDrawer so a slow load for a previous script can't clobber a reopened one. - useWorkspaceScriptSettings: clear loading in the superseded/early-return path so a hub/empty step can't spin forever. - ScriptBuilder: gate the clickable settings badges on customUi.topBar.settings and settingsPanel.disableRuntime. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): round-4 review — template, load-error, legacy-zero handling - WorkspaceScriptSettingsDrawer: stop forcing is_template=false so saving a setting on a template keeps its template status; show a recoverable error (with Retry) when the settings load fails instead of spinning forever. - scriptSettings/FlowModuleComponent: treat non-positive concurrent_limit and timeout as unset (legacy zero rows), so no "Max 0 executions"/"Timeout 0s". - Add badge tests for the non-positive cases. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): round-5 nits — neutral card wording, load-error surfacing, cache zero - WorkspaceScriptSettingInfo: neutral "managed on the referenced workspace script" header (no longer claims "configured" when unset) and a distinct error line so a failed load isn't misread as "not set". - useWorkspaceScriptSettings: expose an error state; thread it into the concurrency and cache cards. - Treat cache_ttl <= 0 as unset, matching concurrency/timeout; add test. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(flow-editor): icon-only script action buttons + gate settings in local-dev - Gate the workspace-script settings actions (header button, clickable badges, Concurrency/Cache shortcuts) on the settings drawer actually being mounted, so the local-dev flow editors (Dev.svelte / flows/dev) that provide the context store but never render the drawer keep the values read-only instead of showing no-op controls. - Make the script action buttons icon-only with clear hover popovers to save space in the crowded step/script-editor top bars: Edit, Settings and Fork in the step header, Settings in the edit-code drawer, and the settings badges (icon chip + label/value popover). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): round-6 nits — a11y names + accurate read-only reason - Add aria-label to the icon-only Edit/Settings/Fork buttons and the setting badges so keyboard/screen-reader users get an accessible name (the hover popover alone didn't expose it). - WorkspaceScriptSettingInfo takes a noEditReason so the read-only explanation matches the actual gate (hub / hash-pinned / unavailable-in-this-editor) instead of always blaming hub/pinned — fixes the wrong reason shown in the local-dev flow editors. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(flow-editor): drop narrating comment on the no-edit-reason derived Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): bind settings save completion to the drawer target The drawer is a singleton, so a save that outlived a reopen ran the new target's callback and closed its drawer, discarding edits in progress. Capture the target sequence and callback at save time: the captured callback still fires (it refreshes the script it belongs to) while the close, error toast and saving flag only apply if the target is unchanged. Reopening also resets the saving flag, which the seq-guarded save no longer clears for a superseded target. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/Dev.svelte | 2 + .../src/lib/components/FlowBuilder.svelte | 6 + .../components/ScriptAdvancedSettings.svelte | 286 ++++++++++++++++++ .../src/lib/components/ScriptBuilder.svelte | 15 + .../components/ScriptSettingsBadges.svelte | 42 +++ .../flows/content/FlowModuleCache.svelte | 37 ++- .../flows/content/FlowModuleComponent.svelte | 108 ++++++- .../flows/content/FlowModuleHeader.svelte | 93 ++++-- .../flows/content/ScriptEditorDrawer.svelte | 70 ++++- .../content/WorkspaceScriptSettingInfo.svelte | 61 ++++ .../WorkspaceScriptSettingsDrawer.svelte | 150 +++++++++ frontend/src/lib/components/flows/types.ts | 2 + .../useWorkspaceScriptSettings.svelte.ts | 72 +++++ .../src/lib/components/scriptSettings.test.ts | 53 ++++ frontend/src/lib/components/scriptSettings.ts | 137 +++++++++ frontend/src/routes/flows/dev/+page.svelte | 10 +- 16 files changed, 1101 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/components/ScriptAdvancedSettings.svelte create mode 100644 frontend/src/lib/components/ScriptSettingsBadges.svelte create mode 100644 frontend/src/lib/components/flows/content/WorkspaceScriptSettingInfo.svelte create mode 100644 frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte create mode 100644 frontend/src/lib/components/flows/useWorkspaceScriptSettings.svelte.ts create mode 100644 frontend/src/lib/components/scriptSettings.test.ts create mode 100644 frontend/src/lib/components/scriptSettings.ts diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index ca8ecb8451..6d9b21aeb9 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -662,6 +662,7 @@ const previewArgsStore = $state({ val: {} }) const scriptEditorDrawer = writable(undefined) + const workspaceScriptSettingsDrawer = writable(undefined) const history = initHistory(flowStore.val) const stepsInputArgs = new StepsInputArgs() const selectionManager = new SelectionManager() @@ -687,6 +688,7 @@ selectionManager, previewArgs: previewArgsStore, scriptEditorDrawer, + workspaceScriptSettingsDrawer, flowEditorDrawer: writable(undefined), history, pathStore: pathStore, diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index b30db19af9..3ce0a9fa03 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -40,6 +40,7 @@ import { Button } from './common' import FlowEditor from './flows/FlowEditor.svelte' import ScriptEditorDrawer from './flows/content/ScriptEditorDrawer.svelte' + import WorkspaceScriptSettingsDrawer from './flows/content/WorkspaceScriptSettingsDrawer.svelte' import FlowEditorDrawer from './flows/content/FlowEditorDrawer.svelte' import { dfs as dfsApply } from './flows/dfs' import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte' @@ -540,6 +541,9 @@ const previewArgsStore = $state({ val: untrack(() => initialArgs) }) const scriptEditorDrawer = writable(undefined) + const workspaceScriptSettingsDrawer = writable( + undefined + ) const flowEditorDrawer = writable(undefined) const history = initHistory(untrack(() => flowStore).val) const pathStore = writable(untrack(() => pathStoreInit) ?? initialPath) @@ -589,6 +593,7 @@ currentEditor: writable(undefined), previewArgs: previewArgsStore, scriptEditorDrawer, + workspaceScriptSettingsDrawer, flowEditorDrawer, history, flowStateStore: untrack(() => flowStateStore), @@ -1139,6 +1144,7 @@ +
diff --git a/frontend/src/lib/components/ScriptAdvancedSettings.svelte b/frontend/src/lib/components/ScriptAdvancedSettings.svelte new file mode 100644 index 0000000000..67fadbe36a --- /dev/null +++ b/frontend/src/lib/components/ScriptAdvancedSettings.svelte @@ -0,0 +1,286 @@ + + +
+
+ {#snippet header()} + + The script will be executed on a worker configured to listen to this worker group tag + (queue). For instance, you could setup an "highmem", or "gpu" tag. + + {/snippet} + +
+ +
+ {#snippet header()} + + Allowed concurrency within a given timeframe + + {/snippet} + { + if (script.concurrent_limit && script.concurrent_limit != undefined) { + script.concurrent_limit = undefined + script.concurrency_time_window_s = undefined + script.concurrency_key = undefined + } else { + script.concurrent_limit = 1 + } + }} + options={{ right: 'Concurrency limits' }} + /> + {#if Boolean(script.concurrent_limit)} +
+ + + +
+ {/if} +
+ +
+ {#snippet header()} + + Cache the results for each possible inputs + + {/snippet} +
+ !!script.cache_ttl, (v) => (script.cache_ttl = v ? 300 : undefined)} + options={{ right: 'Cache the results for each possible inputs' }} + /> + {#if script.cache_ttl} +
How long to keep the cache valid
+ + script.cache_ignore_s3_path, (v) => (script.cache_ignore_s3_path = v || undefined) + } + options={{ + right: 'Ignore S3 Object paths for caching purposes', + rightTooltip: + 'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.' + }} + /> + {/if} +
+
+ +
+ {#snippet header()} + + Add a custom timeout for this script + + {/snippet} +
+ { + if (script.timeout && script.timeout != undefined) { + script.timeout = undefined + } else { + script.timeout = 300 + } + }} + options={{ right: 'Add a custom timeout for this script' }} + /> + {#if Boolean(script.timeout)} + Timeout duration + + {/if} +
+
+ +
+ {#snippet header()} + + Debounce Jobs + + {/snippet} + +
+ +
+ {#snippet header()} + + Restart the script upon ending unless cancelled + + {/snippet} + { + script.restart_unless_cancelled = script.restart_unless_cancelled ? undefined : true + }} + options={{ right: 'Restart upon ending unless cancelled' }} + /> +
+ +
+ {#snippet header()} + + In this mode, the script is meant to be run on dedicated workers that run the script at + native speed. Can reach >1500rps per dedicated worker. Only available on enterprise + edition and for Python3, Deno, Bun and Bunnative. + + {/snippet} + { + script.dedicated_worker = script.dedicated_worker ? undefined : true + }} + options={{ right: 'Script is run on dedicated workers' }} + /> + {#if script.dedicated_worker} +
+ + A worker group needs to be configured to listen to this script. Select it in the dedicated + workers section of the worker group configuration. + +
+ {/if} +
+ +
+ {#snippet header()} + + The logs, arguments and results of the job will be completely deleted from Windmill after + the specified delay once it is complete. Set to 0 for immediate deletion. The deletion is + irreversible. This settings ONLY applies when the script is used within a flow or triggered + synchronously. + {#if !$enterpriseLicense} + This option is only available on Windmill Enterprise Edition. + {/if} + + {/snippet} +
+ { + script.delete_after_secs = script.delete_after_secs != null ? undefined : 0 + }} + options={{ right: 'Delete logs, arguments and results after completion' }} + /> + {#if script.delete_after_secs != null} + + {/if} +
+
+ + {#if !isCloudHosted()} +
+ {#snippet header()} + + Jobs from script labeled as high priority take precedence over the other jobs when in the + jobs queue. + {#if !$enterpriseLicense}This is a feature only available on enterprise edition.{/if} + + {/snippet} + 0} + on:change={() => { + script.priority = script.priority ? undefined : 100 + }} + options={{ right: 'Label as high priority' }} + > + {#snippet right()} + { + if (script.priority && script.priority > 100) { + script.priority = 100 + } else if (script.priority && script.priority < 0) { + script.priority = 0 + } + }} + /> + {/snippet} + +
+ {/if} +
diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 831cabb498..4007f3bf0f 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -87,6 +87,7 @@ import DefaultScripts from './DefaultScripts.svelte' import { getContext, onMount, setContext, tick, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' + import ScriptSettingsBadges from './ScriptSettingsBadges.svelte' import AutosaveIndicator from './AutosaveIndicator.svelte' import LabelsInput from './LabelsInput.svelte' @@ -1971,6 +1972,20 @@ {onOpenOthersDrafts} /> {/if} + {#if !condensedHeader} + {@const canOpenRuntime = + customUi?.topBar?.settings != false && + customUi?.settingsPanel?.disableRuntime !== true} + { + selectedTab = 'runtime' + metadataOpen = true + } + : undefined} + /> + {/if}
diff --git a/frontend/src/lib/components/ScriptSettingsBadges.svelte b/frontend/src/lib/components/ScriptSettingsBadges.svelte new file mode 100644 index 0000000000..8d73609891 --- /dev/null +++ b/frontend/src/lib/components/ScriptSettingsBadges.svelte @@ -0,0 +1,42 @@ + + +{#if badges.length > 0} +
+ {#each badges as badge (badge.key)} + + + onclick?.(badge.key) : undefined} + aria-label={`${badge.label}: ${badge.detail}`} + /> + {#snippet text()} + {badge.label} — {badge.detail} + {/snippet} + + {/each} +
+{/if} diff --git a/frontend/src/lib/components/flows/content/FlowModuleCache.svelte b/frontend/src/lib/components/flows/content/FlowModuleCache.svelte index f9744af36c..99fc4c7b3a 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleCache.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleCache.svelte @@ -6,12 +6,29 @@ import type { FlowModule } from '$lib/gen' import { SecondsInput } from '../../common' + import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte' interface Props { flowModule: FlowModule + // For workspace-script steps: the cache_ttl currently set on the referenced + // script, and a shortcut to edit it. Undefined for inline/subflow steps. + workspaceScriptCacheTtl?: number | undefined + loadingWorkspaceScript?: boolean + workspaceScriptError?: string | undefined + canEditWorkspaceScript?: boolean + workspaceScriptNoEditReason?: string | undefined + onEditWorkspaceScript?: () => void } - let { flowModule = $bindable() }: Props = $props() + let { + flowModule = $bindable(), + workspaceScriptCacheTtl = undefined, + loadingWorkspaceScript = false, + workspaceScriptError = undefined, + canEditWorkspaceScript = false, + workspaceScriptNoEditReason = undefined, + onEditWorkspaceScript + }: Props = $props() let isCacheEnabled = $derived(Boolean(flowModule.cache_ttl)) @@ -25,10 +42,22 @@ {/snippet} - {#if flowModule.value.type != 'rawscript'} + {#if flowModule.value.type == 'script'} + + {:else if flowModule.value.type != 'rawscript'}

- The cache settings need to be set in the referenced script/flow settings directly. Cache for - hub scripts is not available yet. + The cache settings need to be set in the referenced flow settings directly.

{:else} ('FlowEditorContext') const selectedId = $derived(selectionManager.getSelectedId()) @@ -180,6 +185,59 @@ let assets = $derived((flowModule.value.type === 'rawscript' && flowModule.value.assets) || []) const flowGraphAssetsCtx = getContext('FlowGraphAssetContext') + // For workspace-script steps, load the referenced script's advanced settings so + // the delegating settings tabs (concurrency, cache, ...) can show current values + // and offer an "Edit script settings" shortcut instead of a bare warning. + const referencedScriptSettings = useWorkspaceScriptSettings( + () => (flowModule.value.type === 'script' ? flowModule.value.path : undefined), + () => (flowModule.value.type === 'script' ? flowModule.value.hash : undefined), + () => opWs + ) + // Hub scripts, hash-pinned steps, and embeddings that disable script editing + // can't have their settings edited from here. The drawer must also be mounted: + // local-dev editors (Dev.svelte / flows/dev) provide the context store but never + // render the drawer, so editing there would be a no-op — keep values read-only. + let canEditWorkspaceScriptSettings = $derived( + flowModule.value.type === 'script' && + !flowModule.value.path?.startsWith('hub/') && + flowModule.value.hash == undefined && + customUi?.scriptEdit != false && + $workspaceScriptSettingsDrawer != undefined + ) + let workspaceScriptNoEditReason = $derived( + flowModule.value.type !== 'script' || canEditWorkspaceScriptSettings + ? undefined + : flowModule.value.path?.startsWith('hub/') + ? 'Hub scripts cannot be edited from here.' + : flowModule.value.hash != undefined + ? 'Steps pinned to a specific version cannot be edited from here.' + : 'Editing script settings is not available in this editor.' + ) + // Non-positive concurrent_limit / cache_ttl are treated as unset by the runtime (legacy rows). + let referencedConcurrentLimit = $derived( + referencedScriptSettings.settings?.concurrent_limit != undefined && + referencedScriptSettings.settings.concurrent_limit > 0 + ? referencedScriptSettings.settings.concurrent_limit + : undefined + ) + let referencedCacheTtl = $derived( + referencedScriptSettings.settings?.cache_ttl != undefined && + referencedScriptSettings.settings.cache_ttl > 0 + ? referencedScriptSettings.settings.cache_ttl + : undefined + ) + function openWorkspaceScriptSettings() { + if (flowModule.value.type !== 'script') return + $workspaceScriptSettingsDrawer?.openDrawer( + flowModule.value.path, + flowModule.value.hash, + async () => { + await referencedScriptSettings.reload() + forceReload++ + } + ) + } + // UI Intent handling for AI tool control useUiIntent(`flow-${flowModule.id}`, { openTab: (tab) => { @@ -770,6 +828,9 @@ flowModule.value.hash = await getLatestHashForScript(flowModule.value.path, opWs) } forceReload++ + // Keep the surfaced concurrency/cache values and badges in sync after + // a settings/code save from the header (path/hash may be unchanged). + await referencedScriptSettings.reload() await reload(flowModule) } if (flowModule.value.type == 'flow') { @@ -991,6 +1052,16 @@ {:else if flowModule.value.type === 'script'} {#if !noEditor && (customUi?.hubCode != false || !flowModule?.value?.path?.startsWith('hub/'))}
+ {#if referencedScriptSettings.settings && getActiveScriptSettingsBadges(referencedScriptSettings.settings).length > 0} +
+ +
+ {/if} {#key forceReload} + {:else if flowModule.value.type == 'script'} + {:else} - The concurrency limit of a workspace script is only settable in the - script metadata itself. For hub scripts, this feature is non available - yet. + The concurrency limit of a referenced flow is only settable in the + flow settings directly. {/if} @@ -1322,7 +1412,15 @@
{:else if advancedSelected === 'cache'}
- +
{:else if advancedSelected === 'early-stop'} diff --git a/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte b/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte index c3d6742b54..b34a78e1db 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte @@ -13,7 +13,8 @@ Repeat, Square, Pin, - Save + Save, + Settings } from 'lucide-svelte' import Popover from '../../Popover.svelte' import type { FlowEditorContext } from '../types' @@ -28,7 +29,7 @@ } let { module, tag }: Props = $props() - const { scriptEditorDrawer, flowEditorDrawer, opWorkspace } = + const { scriptEditorDrawer, workspaceScriptSettingsDrawer, flowEditorDrawer, opWorkspace } = getContext('FlowEditorContext') const dispatch = createEventDispatcher() @@ -107,26 +108,54 @@ {/if} {#if module.value.type === 'script'} {#if !module.value.path.startsWith('hub/') && customUi?.scriptEdit != false} - + + + + + {/snippet} + + diff --git a/frontend/src/lib/components/flows/content/WorkspaceScriptSettingInfo.svelte b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingInfo.svelte new file mode 100644 index 0000000000..505474c24c --- /dev/null +++ b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingInfo.svelte @@ -0,0 +1,61 @@ + + +
+
+ + {label} is managed on the referenced workspace script. + + {#if canEdit} + + {/if} +
+
+ {#if loading} + + Loading current value… + + {:else if error} + Could not load the current value: {error} + {:else if active} + {valueText} + {:else} + Not set on the script. + {/if} +
+ {#if !canEdit && noEditReason} + {noEditReason} + {/if} +
diff --git a/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte new file mode 100644 index 0000000000..3a88e6214a --- /dev/null +++ b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte @@ -0,0 +1,150 @@ + + + + drawer?.closeDrawer()}> + {#if loading} +
+ + Loading +
+ {:else if loadError || !script} +
+ + {loadError ?? 'Script not found.'} + + {#if current} + + {/if} +
+ {:else} +
+
+ {script.path} + +
+

+ Saving creates a new version of the workspace script with these runtime settings. The code + is left unchanged. +

+ +
+ {/if} + {#snippet actions()} + + {/snippet} +
+
diff --git a/frontend/src/lib/components/flows/types.ts b/frontend/src/lib/components/flows/types.ts index 41165865ae..a527bd765d 100644 --- a/frontend/src/lib/components/flows/types.ts +++ b/frontend/src/lib/components/flows/types.ts @@ -2,6 +2,7 @@ import type { Job, OpenFlow } from '$lib/gen' import type { History } from '$lib/history.svelte' import type { Writable } from 'svelte/store' import type ScriptEditorDrawer from './content/ScriptEditorDrawer.svelte' +import type WorkspaceScriptSettingsDrawer from './content/WorkspaceScriptSettingsDrawer.svelte' import type FlowEditorDrawer from './content/FlowEditorDrawer.svelte' import type { FlowState } from './flowState' import type { FlowBuilderWhitelabelCustomUi } from '../custom_ui' @@ -76,6 +77,7 @@ export type FlowEditorContext = { currentEditor: Writable previewArgs: StateStore> scriptEditorDrawer: Writable + workspaceScriptSettingsDrawer: Writable flowEditorDrawer: Writable history: History pathStore: Writable diff --git a/frontend/src/lib/components/flows/useWorkspaceScriptSettings.svelte.ts b/frontend/src/lib/components/flows/useWorkspaceScriptSettings.svelte.ts new file mode 100644 index 0000000000..861f85e7bf --- /dev/null +++ b/frontend/src/lib/components/flows/useWorkspaceScriptSettings.svelte.ts @@ -0,0 +1,72 @@ +import { ScriptService } from '$lib/gen' +import type { ScriptAdvancedSettingsFields } from '$lib/components/scriptSettings' + +// Loads the advanced runtime settings (concurrency, cache, timeout, ...) of the +// workspace script referenced by a flow step, so the flow editor can surface the +// current values instead of only a "set it on the script" warning. Reactive to +// the path/hash/workspace getters; call reload() after saving new settings. +export function useWorkspaceScriptSettings( + pathGetter: () => string | undefined, + hashGetter: () => string | undefined, + workspaceGetter: () => string | undefined +) { + let settings = $state(undefined) + let loading = $state(false) + let error = $state(undefined) + // Guards against an older in-flight load resolving after a newer one and + // clobbering the displayed settings when path/hash change quickly. + let loadSeq = 0 + + async function load( + path: string | undefined, + hash: string | undefined, + workspace: string | undefined + ) { + const seq = ++loadSeq + if (!path || !workspace || path.startsWith('hub/')) { + settings = undefined + error = undefined + // Clear here too: this supersedes any in-flight load, whose guarded + // finally can no longer reset loading, else the card spins forever. + loading = false + return + } + loading = true + error = undefined + try { + const script = hash + ? await ScriptService.getScriptByHash({ workspace, hash }) + : await ScriptService.getScriptByPath({ workspace, path }) + if (seq !== loadSeq) return + settings = script as ScriptAdvancedSettingsFields + } catch (e) { + console.error('Could not load referenced script settings', e) + if (seq === loadSeq) { + settings = undefined + // Surface failure so cards distinguish "load failed" from "not set". + error = `${(e as { body?: string })?.body ?? e}` + } + } finally { + if (seq === loadSeq) loading = false + } + } + + $effect(() => { + load(pathGetter(), hashGetter(), workspaceGetter()) + }) + + return { + get settings() { + return settings + }, + get loading() { + return loading + }, + get error() { + return error + }, + reload() { + return load(pathGetter(), hashGetter(), workspaceGetter()) + } + } +} diff --git a/frontend/src/lib/components/scriptSettings.test.ts b/frontend/src/lib/components/scriptSettings.test.ts new file mode 100644 index 0000000000..f010e3863a --- /dev/null +++ b/frontend/src/lib/components/scriptSettings.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { getActiveScriptSettingsBadges } from './scriptSettings' + +describe('getActiveScriptSettingsBadges', () => { + it('returns no badges for undefined or empty settings', () => { + expect(getActiveScriptSettingsBadges(undefined)).toEqual([]) + expect(getActiveScriptSettingsBadges({})).toEqual([]) + }) + + it('only surfaces settings that are actually active', () => { + const keys = getActiveScriptSettingsBadges({ + concurrent_limit: 3, + concurrency_time_window_s: 60, + cache_ttl: 600, + timeout: 120, + priority: 50, + tag: 'gpu' + }).map((b) => b.key) + expect(keys).toEqual(['concurrency', 'cache', 'timeout', 'priority', 'tag']) + }) + + it('treats a zero/absent priority and non-positive debounce as inactive', () => { + const keys = getActiveScriptSettingsBadges({ + priority: 0, + debounce_delay_s: 0 + }).map((b) => b.key) + expect(keys).toEqual([]) + }) + + it('treats non-positive concurrency limits, timeouts and cache ttl as inactive (legacy zero rows)', () => { + const keys = getActiveScriptSettingsBadges({ + concurrent_limit: 0, + timeout: 0, + cache_ttl: 0 + }).map((b) => b.key) + expect(keys).toEqual([]) + }) + + it('keeps delete_after_secs of 0 active (immediate deletion is a real setting)', () => { + const badge = getActiveScriptSettingsBadges({ delete_after_secs: 0 }) + expect(badge.map((b) => b.key)).toEqual(['delete_after_use']) + expect(badge[0].detail).toContain('immediately') + }) + + it('pluralizes the concurrency detail correctly', () => { + expect(getActiveScriptSettingsBadges({ concurrent_limit: 1 })[0].detail).toContain( + 'Max 1 execution' + ) + expect(getActiveScriptSettingsBadges({ concurrent_limit: 2 })[0].detail).toContain( + 'Max 2 executions' + ) + }) +}) diff --git a/frontend/src/lib/components/scriptSettings.ts b/frontend/src/lib/components/scriptSettings.ts new file mode 100644 index 0000000000..4a3b3ad5b8 --- /dev/null +++ b/frontend/src/lib/components/scriptSettings.ts @@ -0,0 +1,137 @@ +import { + Gauge, + Database, + Timer, + Hourglass, + Repeat, + Cpu, + Trash2, + ChevronsUp, + Tag +} from 'lucide-svelte' +import type { ScriptLang } from '$lib/gen' + +// Subset of Script/NewScript fields that make up the "advanced runtime settings" +// surfaced both in the standalone script editor and, via the mini settings drawer, +// from within the flow editor for workspace-script steps. +export type ScriptAdvancedSettingsFields = { + path?: string + language?: ScriptLang + schema?: unknown + tag?: string + concurrent_limit?: number + concurrency_time_window_s?: number + concurrency_key?: string + cache_ttl?: number + cache_ignore_s3_path?: boolean + timeout?: number + debounce_delay_s?: number + debounce_key?: string + debounce_args_to_accumulate?: string[] + max_total_debouncing_time?: number + max_total_debounces_amount?: number + restart_unless_cancelled?: boolean + dedicated_worker?: boolean + delete_after_secs?: number + priority?: number +} + +export type ScriptSettingsBadge = { + key: string + label: string + icon: any + detail: string +} + +// Compute the list of active advanced settings for a script, used to render +// at-a-glance badges in the editor top bar and in the flow drawers. +export function getActiveScriptSettingsBadges( + settings: ScriptAdvancedSettingsFields | undefined +): ScriptSettingsBadge[] { + if (!settings) return [] + const badges: ScriptSettingsBadge[] = [] + // Non-positive concurrent_limit / timeout are treated as unset by the runtime + // (legacy zero rows), so don't surface them as active settings. + if (settings.concurrent_limit != undefined && settings.concurrent_limit > 0) { + badges.push({ + key: 'concurrency', + label: 'Concurrency', + icon: Gauge, + detail: `Max ${settings.concurrent_limit} execution${ + settings.concurrent_limit === 1 ? '' : 's' + }${ + settings.concurrency_time_window_s != undefined + ? ` / ${settings.concurrency_time_window_s}s` + : '' + }` + }) + } + if (settings.cache_ttl != undefined && settings.cache_ttl > 0) { + badges.push({ + key: 'cache', + label: 'Cache', + icon: Database, + detail: `Cached for ${settings.cache_ttl}s` + }) + } + if (settings.timeout != undefined && settings.timeout > 0) { + badges.push({ + key: 'timeout', + label: 'Timeout', + icon: Timer, + detail: `${settings.timeout}s` + }) + } + if (settings.debounce_delay_s != undefined && settings.debounce_delay_s > 0) { + badges.push({ + key: 'debounce', + label: 'Debounce', + icon: Hourglass, + detail: `Debounced by ${settings.debounce_delay_s}s` + }) + } + if (settings.restart_unless_cancelled) { + badges.push({ + key: 'perpetual', + label: 'Perpetual', + icon: Repeat, + detail: 'Restarts unless cancelled' + }) + } + if (settings.dedicated_worker) { + badges.push({ + key: 'dedicated', + label: 'Dedicated', + icon: Cpu, + detail: 'Runs on dedicated workers' + }) + } + if (settings.delete_after_secs != undefined) { + badges.push({ + key: 'delete_after_use', + label: 'Delete after use', + icon: Trash2, + detail: + settings.delete_after_secs === 0 + ? 'Deleted immediately after completion' + : `Deleted ${settings.delete_after_secs}s after completion` + }) + } + if (settings.priority != undefined && settings.priority > 0) { + badges.push({ + key: 'priority', + label: 'High priority', + icon: ChevronsUp, + detail: `Priority ${settings.priority}` + }) + } + if (settings.tag) { + badges.push({ + key: 'tag', + label: settings.tag, + icon: Tag, + detail: `Worker tag: ${settings.tag}` + }) + } + return badges +} diff --git a/frontend/src/routes/flows/dev/+page.svelte b/frontend/src/routes/flows/dev/+page.svelte index 620cd60523..fdb3c512f1 100644 --- a/frontend/src/routes/flows/dev/+page.svelte +++ b/frontend/src/routes/flows/dev/+page.svelte @@ -77,6 +77,7 @@ const previewArgsStore = $state({ val: {} }) const scriptEditorDrawer = writable(undefined) + const workspaceScriptSettingsDrawer = writable(undefined) const history = initHistory(flowStore.val) const stepsInputArgs = new StepsInputArgs() @@ -94,6 +95,7 @@ selectionManager, previewArgs: previewArgsStore, scriptEditorDrawer, + workspaceScriptSettingsDrawer, flowEditorDrawer: writable(undefined), history, pathStore: writable(''), @@ -252,7 +254,7 @@ const selectedId = $derived(selectionManager.getSelectedId()) const selectedModule = $derived( selectedId && flowStore.val?.value - ? findModuleInFlow(flowStore.val.value, selectedId) ?? undefined + ? (findModuleInFlow(flowStore.val.value, selectedId) ?? undefined) : undefined ) @@ -293,7 +295,11 @@ {/if} -
+