mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-26 00:01:37 +00:00
feat: surface workspace-script advanced settings in flow editor (#10289)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* chore(flow-editor): drop narrating comment on the no-edit-reason derived
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<ScriptEditorDrawer | undefined>(undefined)
|
||||
const workspaceScriptSettingsDrawer = writable<WorkspaceScriptSettingsDrawer | undefined>(
|
||||
undefined
|
||||
)
|
||||
const flowEditorDrawer = writable<FlowEditorDrawer | undefined>(undefined)
|
||||
const history = initHistory(untrack(() => flowStore).val)
|
||||
const pathStore = writable<string>(untrack(() => pathStoreInit) ?? initialPath)
|
||||
@@ -589,6 +593,7 @@
|
||||
currentEditor: writable(undefined),
|
||||
previewArgs: previewArgsStore,
|
||||
scriptEditorDrawer,
|
||||
workspaceScriptSettingsDrawer,
|
||||
flowEditorDrawer,
|
||||
history,
|
||||
flowStateStore: untrack(() => flowStateStore),
|
||||
@@ -1139,6 +1144,7 @@
|
||||
<FlowYamlEditor bind:drawer={yamlEditorDrawer} />
|
||||
<FlowImportExportMenu bind:drawer={jsonViewerDrawer} />
|
||||
<ScriptEditorDrawer bind:this={$scriptEditorDrawer} />
|
||||
<WorkspaceScriptSettingsDrawer bind:this={$workspaceScriptSettingsDrawer} />
|
||||
<FlowEditorDrawer bind:this={$flowEditorDrawer} />
|
||||
|
||||
<div bind:this={flowBuilderRoot} class="flex flex-col h-full">
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler } from 'svelte/legacy'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import { SecondsInput } from '$lib/components/common'
|
||||
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
|
||||
import DebounceLimit from '$lib/components/flows/DebounceLimit.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import type { Schema } from '$lib/common'
|
||||
import type { ScriptAdvancedSettingsFields } from './scriptSettings'
|
||||
|
||||
const bubble = createBubbler()
|
||||
|
||||
interface Props {
|
||||
script: ScriptAdvancedSettingsFields
|
||||
// Workspace to read worker tags from (defaults to $workspaceStore inside the picker).
|
||||
workspaceId?: string | undefined
|
||||
}
|
||||
|
||||
let { script = $bindable(), workspaceId = undefined }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<Section label="Worker group tag (queue)">
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups">
|
||||
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.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<WorkerTagPicker bind:tag={script.tag} {workspaceId} />
|
||||
</Section>
|
||||
|
||||
<Section label="Concurrency limits" eeOnly>
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits">
|
||||
Allowed concurrency within a given timeframe
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={Boolean(script.concurrent_limit)}
|
||||
on:change={() => {
|
||||
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)}
|
||||
<div class="flex flex-col gap-4 mt-2">
|
||||
<Label label="Max number of executions within the time window">
|
||||
<div class="flex flex-row gap-2 max-w-sm whitespace-nowrap">
|
||||
<input
|
||||
disabled={!$enterpriseLicense}
|
||||
bind:value={script.concurrent_limit}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Label label="Time window in seconds">
|
||||
<SecondsInput
|
||||
disabled={!$enterpriseLicense}
|
||||
bind:seconds={script.concurrency_time_window_s}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Custom concurrency key (optional)">
|
||||
{#snippet header()}
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits#custom-concurrency-key"
|
||||
>
|
||||
Concurrency keys are global, you can have them be workspace specific using the
|
||||
variable `$workspace`. You can also use an argument's value using `$args[name_of_arg]`</Tooltip
|
||||
>
|
||||
{/snippet}
|
||||
<input
|
||||
disabled={!$enterpriseLicense}
|
||||
type="text"
|
||||
bind:value={script.concurrency_key}
|
||||
placeholder={`$workspace/script/${script.path ?? ''}-$args[foo]`}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
<Section label="Cache">
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/caching">
|
||||
Cache the results for each possible inputs
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<div class="flex gap-2 shrink flex-col">
|
||||
<Toggle
|
||||
size="sm"
|
||||
bind:checked={() => !!script.cache_ttl, (v) => (script.cache_ttl = v ? 300 : undefined)}
|
||||
options={{ right: 'Cache the results for each possible inputs' }}
|
||||
/>
|
||||
{#if script.cache_ttl}
|
||||
<div class="text-2xs text-secondary">How long to keep the cache valid</div>
|
||||
<SecondsInput bind:seconds={script.cache_ttl} />
|
||||
<Toggle
|
||||
size="2xs"
|
||||
bind:checked={
|
||||
() => 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}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label="Timeout">
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/script_editor/settings#timeout">
|
||||
Add a custom timeout for this script
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<div class="flex gap-2 shrink flex-col">
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={Boolean(script.timeout)}
|
||||
on:change={() => {
|
||||
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)}
|
||||
<span class="text-xs font-semibold text-emphasis leading-none mt-2">Timeout duration</span>
|
||||
<SecondsInput bind:seconds={script.timeout} />
|
||||
{/if}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label="Debouncing">
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/job_debouncing">
|
||||
Debounce Jobs
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<DebounceLimit
|
||||
size="sm"
|
||||
bind:debounce_delay_s={script.debounce_delay_s}
|
||||
bind:debounce_key={script.debounce_key}
|
||||
bind:debounce_args_to_accumulate={script.debounce_args_to_accumulate}
|
||||
bind:max_total_debouncing_time={script.max_total_debouncing_time}
|
||||
bind:max_total_debounces_amount={script.max_total_debounces_amount}
|
||||
schema={script.schema as Schema}
|
||||
placeholder={`$workspace/script/${script.path ?? ''}-$args[foo]`}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section label="Perpetual script">
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/script_editor/perpetual_scripts">
|
||||
Restart the script upon ending unless cancelled
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={Boolean(script.restart_unless_cancelled)}
|
||||
on:change={() => {
|
||||
script.restart_unless_cancelled = script.restart_unless_cancelled ? undefined : true
|
||||
}}
|
||||
options={{ right: 'Restart upon ending unless cancelled' }}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section label="Dedicated workers" eeOnly>
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/dedicated_workers">
|
||||
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.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<Toggle
|
||||
disabled={!$enterpriseLicense ||
|
||||
isCloudHosted() ||
|
||||
(script.language != 'bun' &&
|
||||
script.language != 'bunnative' &&
|
||||
script.language != 'python3' &&
|
||||
script.language != 'deno')}
|
||||
size="sm"
|
||||
checked={Boolean(script.dedicated_worker)}
|
||||
on:change={() => {
|
||||
script.dedicated_worker = script.dedicated_worker ? undefined : true
|
||||
}}
|
||||
options={{ right: 'Script is run on dedicated workers' }}
|
||||
/>
|
||||
{#if script.dedicated_worker}
|
||||
<div class="py-2">
|
||||
<Alert type="info" title="Require dedicated workers">
|
||||
A worker group needs to be configured to listen to this script. Select it in the dedicated
|
||||
workers section of the worker group configuration.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
<Section label="Delete after completion">
|
||||
{#snippet header()}
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/script_editor/settings#delete-after-use"
|
||||
>
|
||||
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}
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<div class="flex gap-2 shrink flex-col">
|
||||
<Toggle
|
||||
disabled={!$enterpriseLicense}
|
||||
size="sm"
|
||||
checked={script.delete_after_secs != null}
|
||||
on:change={() => {
|
||||
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}
|
||||
<SecondsInput bind:seconds={script.delete_after_secs} disabled={!$enterpriseLicense} />
|
||||
{/if}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{#if !isCloudHosted()}
|
||||
<Section label="High priority script" eeOnly>
|
||||
{#snippet header()}
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/jobs#high-priority-jobs"
|
||||
>
|
||||
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}
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<Toggle
|
||||
disabled={!$enterpriseLicense || isCloudHosted()}
|
||||
size="sm"
|
||||
checked={script.priority !== undefined && script.priority > 0}
|
||||
on:change={() => {
|
||||
script.priority = script.priority ? undefined : 100
|
||||
}}
|
||||
options={{ right: 'Label as high priority' }}
|
||||
>
|
||||
{#snippet right()}
|
||||
<input
|
||||
type="number"
|
||||
class="!w-16 ml-4"
|
||||
disabled={script.priority === undefined}
|
||||
bind:value={script.priority}
|
||||
onfocus={bubble('focus')}
|
||||
onchange={() => {
|
||||
if (script.priority && script.priority > 100) {
|
||||
script.priority = 100
|
||||
} else if (script.priority && script.priority < 0) {
|
||||
script.priority = 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Toggle>
|
||||
</Section>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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}
|
||||
<ScriptSettingsBadges
|
||||
settings={script}
|
||||
onclick={canOpenRuntime
|
||||
? () => {
|
||||
selectedTab = 'runtime'
|
||||
metadataOpen = true
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import {
|
||||
getActiveScriptSettingsBadges,
|
||||
type ScriptAdvancedSettingsFields
|
||||
} from './scriptSettings'
|
||||
|
||||
interface Props {
|
||||
settings: ScriptAdvancedSettingsFields | undefined
|
||||
// When provided, badges become clickable and call this with the badge key
|
||||
// (e.g. to open the settings drawer focused on that section).
|
||||
onclick?: (key: string) => void
|
||||
small?: boolean
|
||||
}
|
||||
|
||||
let { settings, onclick, small = true }: Props = $props()
|
||||
|
||||
let badges = $derived(getActiveScriptSettingsBadges(settings))
|
||||
</script>
|
||||
|
||||
{#if badges.length > 0}
|
||||
<div class="flex flex-row flex-wrap gap-1 items-center">
|
||||
{#each badges as badge (badge.key)}
|
||||
<!-- Icon-only chips to save space in crowded top bars; the label and current
|
||||
value are shown in the hover popover. -->
|
||||
<Popover notClickable placement="bottom">
|
||||
<Badge
|
||||
color="blue"
|
||||
{small}
|
||||
icon={{ icon: badge.icon, position: 'left' }}
|
||||
clickable={Boolean(onclick)}
|
||||
onclick={onclick ? () => onclick?.(badge.key) : undefined}
|
||||
aria-label={`${badge.label}: ${badge.detail}`}
|
||||
/>
|
||||
{#snippet text()}
|
||||
<span class="font-semibold">{badge.label}</span> — {badge.detail}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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))
|
||||
</script>
|
||||
@@ -25,10 +42,22 @@
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
|
||||
{#if flowModule.value.type != 'rawscript'}
|
||||
{#if flowModule.value.type == 'script'}
|
||||
<WorkspaceScriptSettingInfo
|
||||
label="Cache"
|
||||
active={workspaceScriptCacheTtl != undefined}
|
||||
valueText={workspaceScriptCacheTtl != undefined
|
||||
? `Cached for ${workspaceScriptCacheTtl}s`
|
||||
: undefined}
|
||||
loading={loadingWorkspaceScript}
|
||||
error={workspaceScriptError}
|
||||
canEdit={canEditWorkspaceScript}
|
||||
noEditReason={workspaceScriptNoEditReason}
|
||||
onEdit={onEditWorkspaceScript}
|
||||
/>
|
||||
{:else if flowModule.value.type != 'rawscript'}
|
||||
<p class="text-xs text-secondary">
|
||||
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.
|
||||
</p>
|
||||
{:else}
|
||||
<Toggle
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
import { type Job } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { checkIfParentLoop } from '../utils.svelte'
|
||||
import { useWorkspaceScriptSettings } from '../useWorkspaceScriptSettings.svelte'
|
||||
import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte'
|
||||
import { getActiveScriptSettingsBadges } from '$lib/components/scriptSettings'
|
||||
import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte'
|
||||
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
|
||||
import LogViewer from '$lib/components/LogViewer.svelte'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
@@ -95,7 +99,8 @@
|
||||
saveDraft,
|
||||
customUi,
|
||||
executionCount,
|
||||
opWorkspace
|
||||
opWorkspace,
|
||||
workspaceScriptSettingsDrawer
|
||||
} = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
const selectedId = $derived(selectionManager.getSelectedId())
|
||||
@@ -180,6 +185,59 @@
|
||||
let assets = $derived((flowModule.value.type === 'rawscript' && flowModule.value.assets) || [])
|
||||
const flowGraphAssetsCtx = getContext<FlowGraphAssetContext | undefined>('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/'))}
|
||||
<div class="border-t">
|
||||
{#if referencedScriptSettings.settings && getActiveScriptSettingsBadges(referencedScriptSettings.settings).length > 0}
|
||||
<div class="flex flex-row items-center gap-2 px-2 pt-2 flex-wrap">
|
||||
<ScriptSettingsBadges
|
||||
settings={referencedScriptSettings.settings}
|
||||
onclick={canEditWorkspaceScriptSettings
|
||||
? openWorkspaceScriptSettings
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#key forceReload}
|
||||
<FlowModuleScript
|
||||
bind:tag={workspaceScriptTag}
|
||||
@@ -1251,11 +1322,30 @@
|
||||
placeholder={`$workspace/script/${$pathStore}-$args[foo]`}
|
||||
/>
|
||||
</Label>
|
||||
{:else if flowModule.value.type == 'script'}
|
||||
<WorkspaceScriptSettingInfo
|
||||
label="Concurrency limit"
|
||||
active={referencedConcurrentLimit != undefined}
|
||||
valueText={referencedConcurrentLimit != undefined
|
||||
? `Max ${referencedConcurrentLimit} execution${
|
||||
referencedConcurrentLimit === 1 ? '' : 's'
|
||||
}${
|
||||
referencedScriptSettings.settings?.concurrency_time_window_s !=
|
||||
undefined
|
||||
? ` within ${referencedScriptSettings.settings.concurrency_time_window_s}s`
|
||||
: ''
|
||||
}`
|
||||
: undefined}
|
||||
loading={referencedScriptSettings.loading}
|
||||
error={referencedScriptSettings.error}
|
||||
canEdit={canEditWorkspaceScriptSettings}
|
||||
noEditReason={workspaceScriptNoEditReason}
|
||||
onEdit={openWorkspaceScriptSettings}
|
||||
/>
|
||||
{:else}
|
||||
<Alert type="warning" title="Limitation" size="xs">
|
||||
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.
|
||||
</Alert>
|
||||
{/if}
|
||||
</Section>
|
||||
@@ -1322,7 +1412,15 @@
|
||||
</div>
|
||||
{:else if advancedSelected === 'cache'}
|
||||
<div>
|
||||
<FlowModuleCache bind:flowModule />
|
||||
<FlowModuleCache
|
||||
bind:flowModule
|
||||
workspaceScriptCacheTtl={referencedCacheTtl}
|
||||
loadingWorkspaceScript={referencedScriptSettings.loading}
|
||||
workspaceScriptError={referencedScriptSettings.error}
|
||||
canEditWorkspaceScript={canEditWorkspaceScriptSettings}
|
||||
{workspaceScriptNoEditReason}
|
||||
onEditWorkspaceScript={openWorkspaceScriptSettings}
|
||||
/>
|
||||
</div>
|
||||
{:else if advancedSelected === 'early-stop'}
|
||||
<FlowModuleEarlyStop bind:flowModule />
|
||||
|
||||
@@ -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>('FlowEditorContext')
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -107,26 +108,54 @@
|
||||
{/if}
|
||||
{#if module.value.type === 'script'}
|
||||
{#if !module.value.path.startsWith('hub/') && customUi?.scriptEdit != false}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
if (module.value.type == 'script') {
|
||||
const hash =
|
||||
module.value.hash ??
|
||||
(await getLatestHashForScript(module.value.path, opWorkspace?.()))
|
||||
$scriptEditorDrawer?.openDrawer(hash, () => {
|
||||
dispatch('reload')
|
||||
sendUserToast('Script has been updated')
|
||||
})
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: Pen }}
|
||||
iconOnly={false}
|
||||
disabled={module.value.hash != undefined}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Popover notClickable placement="bottom">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
if (module.value.type == 'script') {
|
||||
const hash =
|
||||
module.value.hash ??
|
||||
(await getLatestHashForScript(module.value.path, opWorkspace?.()))
|
||||
$scriptEditorDrawer?.openDrawer(hash, () => {
|
||||
dispatch('reload')
|
||||
sendUserToast('Script has been updated')
|
||||
})
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: Pen }}
|
||||
iconOnly
|
||||
aria-label="Edit the script's code"
|
||||
disabled={module.value.hash != undefined}
|
||||
/>
|
||||
{#snippet text()}Edit the script's code{/snippet}
|
||||
</Popover>
|
||||
<!-- Only when the settings drawer is actually mounted (not in the local-dev
|
||||
editors, which provide the context store but never render it). -->
|
||||
{#if $workspaceScriptSettingsDrawer}
|
||||
<Popover notClickable placement="bottom">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
if (module.value.type == 'script') {
|
||||
$workspaceScriptSettingsDrawer?.openDrawer(
|
||||
module.value.path,
|
||||
module.value.hash,
|
||||
() => {
|
||||
dispatch('reload')
|
||||
}
|
||||
)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: Settings }}
|
||||
iconOnly
|
||||
aria-label="Edit the script's runtime settings"
|
||||
disabled={module.value.hash != undefined}
|
||||
/>
|
||||
{#snippet text()}Edit the script's runtime settings (concurrency, cache, timeout, ...){/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if customUi?.tagEdit != false}
|
||||
<FlowModuleWorkerTagSelect
|
||||
@@ -139,15 +168,17 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if customUi?.scriptFork != false}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => dispatch('fork')}
|
||||
startIcon={{ icon: GitFork }}
|
||||
iconOnly={false}
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
<Popover notClickable placement="bottom">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => dispatch('fork')}
|
||||
startIcon={{ icon: GitFork }}
|
||||
iconOnly
|
||||
aria-label="Fork into an inline script"
|
||||
/>
|
||||
{#snippet text()}Fork into an inline script{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{:else if module.value.type === 'flow'}
|
||||
<Button
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
import { ScriptService, type Preview, type Script } from '$lib/gen'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { Loader2, Save, DiffIcon } from 'lucide-svelte'
|
||||
import { Loader2, Save, DiffIcon, Settings } from 'lucide-svelte'
|
||||
import ScriptAdvancedSettings from '$lib/components/ScriptAdvancedSettings.svelte'
|
||||
import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
emptySchema,
|
||||
emptyString,
|
||||
orderedJsonStringify,
|
||||
sendUserToast
|
||||
} from '$lib/utils'
|
||||
@@ -56,6 +60,20 @@
|
||||
on_behalf_of_email?: string
|
||||
auto_kind?: string
|
||||
has_preprocessor?: boolean
|
||||
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
|
||||
priority?: number
|
||||
delete_after_secs?: number
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
|
||||
@@ -77,6 +95,20 @@
|
||||
on_behalf_of_email?: string
|
||||
auto_kind?: string
|
||||
has_preprocessor?: boolean
|
||||
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
|
||||
priority?: number
|
||||
delete_after_secs?: number
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
|
||||
@@ -102,6 +134,11 @@
|
||||
is_template: false,
|
||||
tag: script.tag,
|
||||
kind: script.kind as Script['kind'] | undefined,
|
||||
// Empty keys are shared global keys server-side; treat cleared inputs as unset.
|
||||
concurrency_key: emptyString(script.concurrency_key)
|
||||
? undefined
|
||||
: script.concurrency_key,
|
||||
debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key,
|
||||
lock: undefined
|
||||
}
|
||||
})
|
||||
@@ -135,6 +172,7 @@
|
||||
let args = $state({})
|
||||
|
||||
let displayEditor = $state(true)
|
||||
let settingsDrawer: Drawer | undefined = $state()
|
||||
</script>
|
||||
|
||||
<ConfirmationModal
|
||||
@@ -239,6 +277,20 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet actions()}
|
||||
{#if script}
|
||||
<ScriptSettingsBadges settings={script} onclick={() => settingsDrawer?.openDrawer()} />
|
||||
{/if}
|
||||
<Popover notClickable placement="bottom">
|
||||
<Button
|
||||
disabled={!script}
|
||||
variant="default"
|
||||
iconOnly
|
||||
startIcon={{ icon: Settings }}
|
||||
aria-label="Runtime settings"
|
||||
on:click={() => settingsDrawer?.openDrawer()}
|
||||
/>
|
||||
{#snippet text()}Runtime settings (concurrency, cache, timeout, ...){/snippet}
|
||||
</Popover>
|
||||
<Button
|
||||
disabled={!savedScript || !script}
|
||||
variant="default"
|
||||
@@ -290,3 +342,19 @@
|
||||
displayEditor = true
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer bind:this={settingsDrawer} size="600px">
|
||||
<DrawerContent title="Script settings" on:close={() => settingsDrawer?.closeDrawer()}>
|
||||
{#if script}
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-secondary">
|
||||
These runtime settings are saved together with the script when you press Save.
|
||||
</p>
|
||||
<ScriptAdvancedSettings {script} workspaceId={opWs} />
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet actions()}
|
||||
<Button variant="border" on:click={() => settingsDrawer?.closeDrawer()}>Done</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { Loader2, Settings } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
// Human label of the setting, e.g. "Concurrency limit" or "Cache".
|
||||
label: string
|
||||
// Whether the setting is currently configured on the referenced script.
|
||||
active: boolean
|
||||
// Rendered summary of the current value (only shown when active).
|
||||
valueText?: string | undefined
|
||||
loading?: boolean
|
||||
// Set when loading the referenced script failed, to distinguish that from "not set".
|
||||
error?: string | undefined
|
||||
// Editing is possible only for non-hub, non-hash-pinned workspace scripts.
|
||||
canEdit: boolean
|
||||
// Explanation shown when editing isn't possible (varies: hub, pinned, dev editor).
|
||||
noEditReason?: string | undefined
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
active,
|
||||
valueText,
|
||||
loading = false,
|
||||
error = undefined,
|
||||
canEdit,
|
||||
noEditReason = undefined,
|
||||
onEdit
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded-md border p-3 bg-surface-secondary">
|
||||
<div class="flex flex-row items-center justify-between gap-2">
|
||||
<span class="text-xs text-secondary">
|
||||
{label} is managed on the referenced workspace script.
|
||||
</span>
|
||||
{#if canEdit}
|
||||
<Button size="xs" variant="border" startIcon={{ icon: Settings }} on:click={() => onEdit?.()}>
|
||||
Edit script settings
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
{#if loading}
|
||||
<span class="text-tertiary inline-flex items-center gap-1">
|
||||
<Loader2 class="animate-spin" size={12} /> Loading current value…
|
||||
</span>
|
||||
{:else if error}
|
||||
<span class="text-red-500">Could not load the current value: {error}</span>
|
||||
{:else if active}
|
||||
<span class="font-semibold text-emphasis">{valueText}</span>
|
||||
{:else}
|
||||
<span class="text-tertiary">Not set on the script.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !canEdit && noEditReason}
|
||||
<span class="text-2xs text-tertiary">{noEditReason}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import ScriptAdvancedSettings from '$lib/components/ScriptAdvancedSettings.svelte'
|
||||
import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte'
|
||||
import { ScriptService, type Script } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { emptyString, sendUserToast } from '$lib/utils'
|
||||
import { Loader2, Save } from 'lucide-svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
|
||||
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
// cache_ignore_s3_path lives on NewScript but not the returned Script type;
|
||||
// ScriptAdvancedSettings still edits it, so widen the local type to keep it.
|
||||
let script: (Script & { cache_ignore_s3_path?: boolean }) | undefined = $state(undefined)
|
||||
let loading = $state(false)
|
||||
let loadError = $state<string | undefined>(undefined)
|
||||
let saving = $state(false)
|
||||
let callback: (() => void) | undefined = undefined
|
||||
let current: { path: string; hash: string | undefined } | undefined = undefined
|
||||
// Guards against a slow load for a previously-opened script resolving after
|
||||
// the drawer was reopened for a different one and overwriting its target.
|
||||
let openSeq = 0
|
||||
|
||||
// Open the settings drawer for the workspace script referenced by `path`.
|
||||
// A specific `hash` may be passed to load that version.
|
||||
export async function openDrawer(
|
||||
path: string,
|
||||
hash: string | undefined,
|
||||
cb?: () => void
|
||||
): Promise<void> {
|
||||
callback = cb
|
||||
drawer?.openDrawer?.()
|
||||
await load(path, hash)
|
||||
}
|
||||
|
||||
async function load(path: string, hash: string | undefined): Promise<void> {
|
||||
const seq = ++openSeq
|
||||
script = undefined
|
||||
loadError = undefined
|
||||
current = { path, hash }
|
||||
loading = true
|
||||
// A save still in flight for the previous target no longer clears this flag
|
||||
// (it is seq-guarded), so reset it here or the new target's Save stays disabled.
|
||||
saving = false
|
||||
try {
|
||||
const loaded = hash
|
||||
? await ScriptService.getScriptByHash({ workspace: opWs!, hash })
|
||||
: await ScriptService.getScriptByPath({ workspace: opWs!, path })
|
||||
if (seq !== openSeq) return
|
||||
script = loaded
|
||||
} catch (e) {
|
||||
if (seq === openSeq) loadError = `${e?.body ?? e}`
|
||||
} finally {
|
||||
if (seq === openSeq) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!script) return
|
||||
// The drawer is a singleton: bind this save to the target that started it, so a
|
||||
// reopen for another script can't receive its callback or get closed under the
|
||||
// user. The captured callback still fires — it refreshes the script it belongs to.
|
||||
const seq = openSeq
|
||||
const cb = callback
|
||||
saving = true
|
||||
try {
|
||||
// Spread the full loaded script so fields we don't edit here (codebase,
|
||||
// labels, envs, on_behalf_of_email, ...) survive the new version; only
|
||||
// override lineage and normalize the edited settings.
|
||||
// parent_hash without auto_parent is an optimistic-concurrency guard: if the
|
||||
// script was deployed elsewhere while the drawer was open, the save fails
|
||||
// loudly (non-linear lineage) instead of silently reverting that deploy.
|
||||
// preserve_on_behalf_of/skip_draft_deletion keep a settings-only save from
|
||||
// hijacking the execution identity or discarding the author's code draft.
|
||||
await ScriptService.createScript({
|
||||
workspace: opWs!,
|
||||
requestBody: {
|
||||
...script,
|
||||
summary: script.summary ?? '',
|
||||
description: script.description ?? '',
|
||||
parent_hash: script.hash,
|
||||
lock: script.lock,
|
||||
concurrency_key: emptyString(script.concurrency_key) ? undefined : script.concurrency_key,
|
||||
debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key,
|
||||
preserve_on_behalf_of: true,
|
||||
skip_draft_deletion: true
|
||||
}
|
||||
})
|
||||
sendUserToast('Script settings saved')
|
||||
cb?.()
|
||||
if (seq === openSeq) drawer?.closeDrawer()
|
||||
} catch (e) {
|
||||
if (seq === openSeq) sendUserToast(`Could not save script settings: ${e.body ?? e}`, true)
|
||||
} finally {
|
||||
if (seq === openSeq) saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="600px">
|
||||
<DrawerContent title="Script settings" on:close={() => drawer?.closeDrawer()}>
|
||||
{#if loading}
|
||||
<div class="center-center flex-col h-full text-tertiary">
|
||||
<Loader2 class="animate-spin" size={16} />
|
||||
<span class="text-xs mt-1">Loading</span>
|
||||
</div>
|
||||
{:else if loadError || !script}
|
||||
<div class="center-center flex-col h-full gap-3">
|
||||
<Alert type="error" title="Could not load script settings" size="xs">
|
||||
{loadError ?? 'Script not found.'}
|
||||
</Alert>
|
||||
{#if current}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
on:click={() => current && load(current.path, current.hash)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span class="text-xs text-tertiary">{script.path}</span>
|
||||
<ScriptSettingsBadges settings={script} />
|
||||
</div>
|
||||
<p class="text-xs text-secondary">
|
||||
Saving creates a new version of the workspace script with these runtime settings. The code
|
||||
is left unchanged.
|
||||
</p>
|
||||
<ScriptAdvancedSettings {script} workspaceId={opWs} />
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
on:click={save}
|
||||
disabled={!script || loading || saving}
|
||||
startIcon={{ icon: saving ? Loader2 : Save }}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -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<CurrentEditor>
|
||||
previewArgs: StateStore<Record<string, any>>
|
||||
scriptEditorDrawer: Writable<ScriptEditorDrawer | undefined>
|
||||
workspaceScriptSettingsDrawer: Writable<WorkspaceScriptSettingsDrawer | undefined>
|
||||
flowEditorDrawer: Writable<FlowEditorDrawer | undefined>
|
||||
history: History<OpenFlow>
|
||||
pathStore: Writable<string>
|
||||
|
||||
@@ -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<ScriptAdvancedSettingsFields | undefined>(undefined)
|
||||
let loading = $state(false)
|
||||
let error = $state<string | undefined>(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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-1 z-50 absolute gap-2 {compactPreview ? 'left-1/2 -translate-x-1/2 top-14' : 'right-2 top-2'}">
|
||||
<div
|
||||
class="flex justify-center pt-1 z-50 absolute gap-2 {compactPreview
|
||||
? 'left-1/2 -translate-x-1/2 top-14'
|
||||
: 'right-2 top-2'}"
|
||||
>
|
||||
<FlowPreviewButtons bind:this={flowPreviewButtons} {suspendStatus} />
|
||||
</div>
|
||||
<Splitpanes horizontal class="max-h-screen grow min-h-0">
|
||||
|
||||
Reference in New Issue
Block a user