Files
windmill/frontend/src/lib/components/flows/content/FlowRunSettings.svelte
T
e7fc1b2e2e feat: restricted job tokens per script and flow (#11484)
* feat: restricted job tokens (job_token_scopes on scripts and flows)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: admit flow-run reads, skip dedicated workers, gate on worker version

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep restricted jobs off flow runners, preserve scopes on rename and promotion

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep restricted jobs off every dedicated handoff, confine progress flow id

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: exclude restricted runnables from dedicated worker startup

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: gate restrictions on the release after 1.821.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: store per-job scopes on job_perms instead of v2_job, pin inline runs to the checked version

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: step-level job_token_scopes for flow steps and agent tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: fail closed on perms read errors, refuse restricted queue imports, gate step scopes in previews

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: carry a job's scopes on its completion so a re-run keeps the caller's cap

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: carry a zombie job's scopes into its completion

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: leave a zombie for the next sweep when its scopes cannot be read

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* docs: correct the QueuedJobV2 completion comment

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: validate step scopes in batch flows, fail closed on unvalidated step scopes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: refuse flows with step or tool restrictions at push while an older worker is live

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: apply the step-scope worker gate to flow restarts

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: list the job token toggle with the other step and flow settings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* chore: pin the EE companion merged with EE main

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* perf: skip scope lookups for unrestricted jobs; list job token setting last

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* style: rustfmt scopes tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: confine restricted job tokens to their own run lineage; drop remaining extra lookups

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* chore: update ee-repo-ref to 259ad3bfeef5285ba80eedc86309b11dca001220

This commit updates the EE repository reference after PR #843 was merged in windmill-ee-private.

Previous ee-repo-ref: 2b77c0225dca441235daf7bf0a06ba968df0c927

New ee-repo-ref: 259ad3bfeef5285ba80eedc86309b11dca001220

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-03 09:33:44 +02:00

396 lines
14 KiB
Svelte

<script lang="ts">
import { getContext } from 'svelte'
import { enterpriseLicense } from '$lib/stores'
import { isCloudHosted } from '$lib/cloud'
import type { FlowModule } from '$lib/gen'
import type { FlowEditorContext } from '../types'
import Toggle from '$lib/components/Toggle.svelte'
import Label from '$lib/components/Label.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { SecondsInput } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import s3Scripts from './s3Scripts/lib'
import FlowRetries from './FlowRetries.svelte'
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
import FlowModuleSkip from './FlowModuleSkip.svelte'
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowModuleTimeout from './FlowModuleTimeout.svelte'
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
import FlowModuleCache from './FlowModuleCache.svelte'
import { hasInlineConcurrency, stepSettingDefaults } from '../flowStepSettings'
import FlowModuleDebounce from './FlowModuleDebounce.svelte'
import FlowModuleMock from './FlowModuleMock.svelte'
import JobTokenScopesSetting from '$lib/components/JobTokenScopesSetting.svelte'
import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte'
import { slideDynamic } from '$lib/transitions'
const { pathStore } = getContext<FlowEditorContext>('FlowEditorContext')
interface Props {
flowModule: FlowModule
parentModule?: FlowModule | undefined
previousModule?: FlowModule | undefined
selectedId: string
/** Lay out for embedding inside another scroll container (no own scroll/padding). */
embedded?: boolean
/** Container steps (loops and branches): keep only the subset of settings they support
* (Flow control rows + Lifetime), hiding the rest of Execution policy. */
loopSubset?: boolean
/** Loops render the per-iteration break next to their own settings, so they ask
* for the all-iterations predicate only. */
earlyStopBlocks?: 'both' | 'all-iters'
// For workspace-script steps: the concurrency/cache settings currently set on
// the referenced script, and a shortcut to edit them. Undefined for
// inline/subflow steps.
referencedConcurrentLimit?: number | undefined
referencedConcurrencyTimeWindowS?: number | undefined
workspaceScriptCacheTtl?: number | undefined
loadingWorkspaceScript?: boolean
workspaceScriptError?: string | undefined
canEditWorkspaceScript?: boolean
workspaceScriptNoEditReason?: string | undefined
onEditWorkspaceScript?: () => void
/** Replace the inline script's code — provided by the step editor. */
onApplyS3Snippet?: (code: string) => void
/** Agent tools never go through the flow scheduler, so the settings `same_worker`
* rules out for a flow step still apply to them. */
isAgentTool?: boolean
}
let {
flowModule = $bindable(),
parentModule = undefined,
previousModule = undefined,
selectedId,
embedded = false,
loopSubset = false,
earlyStopBlocks = 'both',
referencedConcurrentLimit = undefined,
referencedConcurrencyTimeWindowS = undefined,
workspaceScriptCacheTtl = undefined,
loadingWorkspaceScript = false,
workspaceScriptError = undefined,
canEditWorkspaceScript = false,
workspaceScriptNoEditReason = undefined,
onEditWorkspaceScript,
onApplyS3Snippet,
isAgentTool = false
}: Props = $props()
let rootEl: HTMLDivElement | undefined = $state()
/** Scroll one setting into view. `key` is a StepSettingKey, so the AI tool's openTab
* intent must name settings with those keys. */
export function openSetting(key: string) {
rootEl?.querySelector(`[data-setting="${key}"]`)?.scrollIntoView({ block: 'nearest' })
}
// Exact id: only the flow's error handler is 'failure'; a step the user named
// something like handle_failure is an ordinary step and keeps every row.
const isFailure = $derived(selectedId === 'failure')
const isRawScript = $derived(flowModule.value.type === 'rawscript')
const isWorkspaceScript = $derived(flowModule.value.type === 'script')
const s3Language = $derived(
flowModule.value.type === 'rawscript' &&
(flowModule.value.language === 'python3' || flowModule.value.language === 'deno')
? flowModule.value.language
: undefined
)
let s3Kind = $state<'s3_client' | 'polars' | 'duckdb'>('s3_client')
const s3Snippet = $derived(s3Language ? s3Scripts[s3Language][s3Kind] : undefined)
const concurrencyOn = $derived(hasInlineConcurrency(flowModule))
const concurrencyOff = $derived(!$enterpriseLicense || !concurrencyOn)
// A resolved approval is recorded against the step the gate holds back, not this one, so
// `continue_on_error` never sees it — the suspend option is the only way to continue past it.
const suspendNeedsItsOwnContinueToggle = $derived(
Boolean(flowModule.continue_on_error) &&
Boolean(flowModule.suspend) &&
!flowModule.suspend?.continue_on_disapprove_timeout
)
</script>
{#snippet sectionHeader(title: string)}
<div class="text-[11px] font-medium uppercase tracking-[0.04em] text-hint">
{title}
</div>
{/snippet}
<div
bind:this={rootEl}
class="flex flex-col gap-8 {embedded ? '' : 'flex-1 min-h-0 overflow-auto p-4 pb-8'}"
style={embedded ? undefined : 'scrollbar-gutter: stable'}
>
{#if !isFailure}
<section class="flex flex-col gap-3">
{@render sectionHeader('Flow control')}
<div class="flex flex-col gap-6">
<div data-setting="skip">
<FlowModuleSkip bind:flowModule {parentModule} {previousModule} />
</div>
<div data-setting="early-stop">
<FlowModuleEarlyStop bind:flowModule blocks={earlyStopBlocks} />
</div>
<div data-setting="suspend">
<FlowModuleSuspend previousModuleId={previousModule?.id} bind:flowModule />
</div>
<div data-setting="sleep">
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule {isAgentTool} />
</div>
</div></section
>
{/if}
<section class="flex flex-col gap-3">
{@render sectionHeader('Execution policy')}
<div class="flex flex-col gap-6">
{#if !loopSubset}
<div data-setting="retries">
<FlowRetries bind:flowModuleRetry={flowModule.retry} bind:flowModule {isAgentTool} />
</div>
<div data-setting="error-handling" class="flex flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
bind:checked={flowModule.continue_on_error}
options={{
right: 'Continue on error',
rightTooltip:
"The flow continues to the next step even if this step fails (after exhausting retries, if any). The step's error becomes its return, so a following branch can handle it."
}}
/>
{#if suspendNeedsItsOwnContinueToggle}
<Alert type="info" title="Does not cover the approval" size="xs">
This only applies when the step's own code fails. A disapproval or an approval timeout
is not a failure of this step, so it still stops the flow. To continue past those,
turn on "Continue on disapproval/timeout" in the approval settings.
</Alert>
{/if}
</div>
<!-- The error handler runs outside the flow's control graph; only its own
failure handling applies. -->
{#if !isFailure}
<div data-setting="timeout">
<FlowModuleTimeout previousModuleId={previousModule?.id} bind:flowModule />
</div>
{#if isRawScript || isWorkspaceScript}
<div data-setting="concurrency">
{#if flowModule.value.type === 'script'}
<WorkspaceScriptSettingInfo
label="Concurrency limit"
active={referencedConcurrentLimit != undefined}
valueText={referencedConcurrentLimit != undefined
? `Max ${referencedConcurrentLimit} execution${
referencedConcurrentLimit === 1 ? '' : 's'
}${
referencedConcurrencyTimeWindowS != undefined
? ` within ${referencedConcurrencyTimeWindowS}s`
: ''
}`
: undefined}
loading={loadingWorkspaceScript}
error={workspaceScriptError}
canEdit={canEditWorkspaceScript}
noEditReason={workspaceScriptNoEditReason}
onEdit={onEditWorkspaceScript}
/>
{:else if flowModule.value.type === 'rawscript'}
<div class="flex flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
eeOnly
disabled={!$enterpriseLicense}
checked={concurrencyOn}
on:change={() => {
if (flowModule.value.type !== 'rawscript') return
flowModule.value.concurrent_limit = concurrencyOn ? undefined : 1
}}
options={{
right: 'Concurrency limit',
rightTooltip: 'Allowed concurrency within a given timeframe.',
rightDocumentationLink:
'https://www.windmill.dev/docs/flows/concurrency_limit'
}}
/>
{#if concurrencyOn}
<div class="flex flex-col gap-2 pl-9" transition:slideDynamic>
<Label label="Max number of executions within the time window">
<input
disabled={concurrencyOff}
bind:value={flowModule.value.concurrent_limit}
type="number"
min="1"
class="!w-24"
/>
</Label>
<Label label="Time window in seconds">
<SecondsInput
disabled={concurrencyOff}
bind:seconds={flowModule.value.concurrency_time_window_s}
clearable
/>
</Label>
<Label label="Custom concurrency key (optional)">
{#snippet header()}
<Tooltip>
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
type="text"
disabled={concurrencyOff}
bind:value={flowModule.value.custom_concurrency_key}
placeholder={`$workspace/script/${$pathStore}-$args[foo]`}
/>
</Label>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<div data-setting="priority" class="flex flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
eeOnly
disabled={!$enterpriseLicense || isCloudHosted()}
checked={flowModule.priority !== undefined}
on:change={() => {
if (flowModule.priority !== undefined) {
flowModule.priority = undefined
} else {
flowModule.priority = stepSettingDefaults('priority')
}
}}
options={{
right: 'High priority',
rightTooltip:
'Jobs scheduled from this step take precedence over other jobs in the queue when the flow runs.'
}}
/>
{#if flowModule.priority !== undefined}
<div class="pl-9" transition:slideDynamic>
<Label label="Priority number">
{#snippet header()}
<Tooltip>The higher the number, the higher the priority.</Tooltip>
{/snippet}
<input
type="number"
class="!w-24"
bind:value={flowModule.priority}
onchange={() => {
if (flowModule.priority && flowModule.priority > 100) {
flowModule.priority = 100
} else if (flowModule.priority && flowModule.priority < 0) {
flowModule.priority = 0
}
}}
/>
</Label>
</div>
{/if}
<!-- The EE half of this limitation is the toggle's own badge; only the
cloud one needs saying. -->
{#if isCloudHosted()}
<Alert type="warning" title="Limitation" size="xs">
Setting priority is not available on the cloud.
</Alert>
{/if}
</div>
<div data-setting="cache">
<FlowModuleCache
bind:flowModule
{workspaceScriptCacheTtl}
{loadingWorkspaceScript}
{workspaceScriptError}
{canEditWorkspaceScript}
{workspaceScriptNoEditReason}
{onEditWorkspaceScript}
/>
</div>
<div data-setting="debounce">
<FlowModuleDebounce bind:flowModule {selectedId} />
</div>
{/if}
{/if}
{#if !isFailure}
<div data-setting="lifetime">
<FlowModuleDeleteAfterUse bind:flowModule disabled={!$enterpriseLicense} />
</div>
{/if}
{#if loopSubset}
<div data-setting="mock">
<FlowModuleMock bind:flowModule />
</div>
{/if}
<div data-setting="job-token">
<JobTokenScopesSetting
bind:value={flowModule.job_token_scopes}
kind="step"
size="xs"
textClass="text-xs font-normal text-primary"
/>
</div>
</div></section
>
{#if s3Language && onApplyS3Snippet && !isFailure}
<section class="flex flex-col gap-3" data-setting="s3">
{@render sectionHeader('S3 snippets')}
<p class="text-xs text-tertiary">
Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes.
</p>
<div class="flex flex-row items-center justify-between gap-2">
<ToggleButtonGroup bind:selected={s3Kind} class="w-auto">
{#snippet children({ item })}
{#if s3Language === 'deno'}
<ToggleButton value="s3_client" small label="S3 lite client" {item} />
{:else}
<ToggleButton value="s3_client" small label="Boto3" {item} />
<ToggleButton value="polars" small label="Polars" {item} />
<ToggleButton value="duckdb" small label="DuckDB" {item} />
{/if}
{/snippet}
</ToggleButtonGroup>
<Button
size="xs"
variant="default"
on:click={() => s3Snippet && onApplyS3Snippet?.(s3Snippet)}
>
Apply snippet
</Button>
</div>
{#if s3Snippet}
<div class="overflow-auto max-h-64 border rounded-md">
<HighlightCode language={s3Language} code={s3Snippet} />
</div>
{/if}
</section>
{/if}
</div>