Compare commits

...

2 Commits

Author SHA1 Message Date
Ruben Fiszel
51e2dcd86a Merge branch 'main' into claude/issue-6832-20251015-1048 2025-10-17 07:25:38 +00:00
claude[bot]
2375667408 feat: add environment variables support to flows
- Add env_variables field to FlowValue struct in backend
- Update OpenAPI schema to include env_variables field
- Transform FlowConstants.svelte into environment variables manager UI
  - Replace static inputs viewer with env variable key-value editor
  - Add ability to create, edit, and delete environment variables
  - Variables are stored in flow definition and can be referenced as env.FOO
- Update PropPickerWrapper and PropPickerResult to support env context
- Update all PropPickerWrapper usages across flow modules to pass env variables
- Add env declaration to SimpleEditor extraLib for TypeScript autocomplete
- Update FlowStickyNode tooltip from 'Static Inputs' to 'Environment Variables'

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2025-10-15 11:11:01 +00:00
15 changed files with 157 additions and 132 deletions

View File

@@ -139,6 +139,9 @@ pub struct FlowValue {
pub concurrency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_input_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub env_variables: Option<HashMap<String, String>>,
}
impl FlowValue {

View File

@@ -3970,6 +3970,7 @@ pub async fn push<'c, 'd>(
skip_expr: None,
preprocessor_module: None,
chat_input_enabled: None,
env_variables: None,
};
// this is a new flow being pushed, flow_status is set to flow_value:
let flow_status: FlowStatus = FlowStatus::new(&flow_value);

View File

@@ -45,6 +45,7 @@
<PropPickerWrapper
alwaysOn
notSelectable
env={flowStore.val.value.env_variables}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
@@ -59,7 +60,8 @@
bind:code={branch.expr}
class="small-editor border "
shouldBindKey={false}
extraLib={stepPropPicker.extraLib}
extraLib={stepPropPicker.extraLib +
`\ndeclare const env = ${JSON.stringify(flowStore.val.value.env_variables)};`}
/>
</div>
</PropPickerWrapper>

View File

@@ -1,14 +1,11 @@
<script lang="ts">
import { dfs } from '$lib/components/flows/dfs'
import FlowCard from '../common/FlowCard.svelte'
import { Alert, Badge } from '$lib/components/common'
import type { FlowModule, FlowModuleValue, InputTransform, PathScript, RawScript } from '$lib/gen'
import { Alert, Button } from '$lib/components/common'
import { getContext, setContext } from 'svelte'
import type { PropPickerWrapperContext } from '../propPicker/PropPickerWrapper.svelte'
import { writable } from 'svelte/store'
import Toggle from '../../Toggle.svelte'
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
import type { FlowEditorContext } from '../types'
import { Trash, Plus } from 'lucide-svelte'
interface Props {
noEditor: boolean
@@ -16,75 +13,51 @@
let { noEditor }: Props = $props()
let hideOptional = $state(false)
const { flowStateStore, flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
let scriptModules = $derived(
dfs(flowStore.val.value.modules, (x) => x)
.map((x) => [x.value, x] as [FlowModuleValue, FlowModule])
.filter((x) => x[0].type == 'script' || x[0].type == 'rawscript' || x[0].type == 'flow') as [
PathScript | RawScript,
FlowModule
][]
)
// Initialize env_variables if it doesn't exist
if (!flowStore.val.value.env_variables) {
flowStore.val.value.env_variables = {}
}
let resources = $derived(
Object.fromEntries(
scriptModules
.map(([v, m]) => [
m.id,
Object.entries(v.input_transforms)
.map((x) => {
let schema = flowStateStore.val[m.id]?.schema
let val: { argName: string; type: string } | undefined = undefined
let envVariables = $derived(flowStore.val.value.env_variables ?? {})
let envEntries = $derived(Object.entries(envVariables))
const [k, inputTransform] = x
const v = schema?.properties[k]
if (
v?.format?.includes('resource') &&
inputTransform.type === 'static' &&
(inputTransform.value === '' ||
inputTransform.value === undefined ||
inputTransform.value === null)
) {
val = {
argName: k,
type: v.format.split('-')[1]
}
}
return val
})
.filter(Boolean)
])
.filter((x) => x[1].length > 0)
) as {
[k: string]: {
argName: string
type: string
}[]
function addEnvVariable() {
if (!flowStore.val.value.env_variables) {
flowStore.val.value.env_variables = {}
}
)
let steps = $derived(
scriptModules
.map(
([v, m]) =>
[
v.input_transforms,
Object.entries(v.input_transforms)
.filter((x) => {
const shouldDisplay = hideOptional
? flowStateStore.val[m.id]?.schema?.required?.includes(x[0])
: true
return x[1].type == 'static' && shouldDisplay
})
.map((x) => x[0]),
m
] as [Record<string, InputTransform>, string[], FlowModule]
)
.filter(([i, f, m]) => f.length > 0)
)
// Find a unique key name
let counter = 1
let newKey = `NEW_VAR`
while (flowStore.val.value.env_variables[newKey]) {
newKey = `NEW_VAR_${counter}`
counter++
}
flowStore.val.value.env_variables[newKey] = ''
}
function removeEnvVariable(key: string) {
if (flowStore.val.value.env_variables) {
delete flowStore.val.value.env_variables[key]
}
}
function updateEnvVariableKey(oldKey: string, newKey: string) {
if (flowStore.val.value.env_variables && oldKey !== newKey) {
const value = flowStore.val.value.env_variables[oldKey]
delete flowStore.val.value.env_variables[oldKey]
flowStore.val.value.env_variables[newKey] = value
}
}
function updateEnvVariableValue(key: string, value: string) {
if (flowStore.val.value.env_variables) {
flowStore.val.value.env_variables[key] = value
}
}
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
inputMatches: writable(undefined),
@@ -95,63 +68,78 @@
</script>
<div class="min-h-full">
<FlowCard {noEditor} title="All Static Inputs">
<FlowCard {noEditor} title="Environment Variables">
{#snippet header()}
<Toggle bind:checked={hideOptional} options={{ left: 'Hide optional inputs' }} />
<Button
size="xs"
color="blue"
variant="border"
startIcon={{ icon: Plus }}
onclick={addEnvVariable}
>
Add Variable
</Button>
{/snippet}
<div class="min-h-full flex-1">
<Alert type="info" title="Static Inputs" class="m-4"
>This page centralizes the static inputs of every steps. It is aking to a file containing
all constants. Modifying a value here modifies it in the step input directly. It is
especially useful when forking a flow to get an overview of all the variables to parametrize
that are not exposed directly as flow inputs.</Alert
>
{#if Object.keys(resources).length > 0}
<Alert type="warning" title="Missing resources" class="m-4">
The following resources are missing and the flow will not be fully runnable until they are
set. Add your own resources:
{#each Object.entries(resources) as [id, r]}
{#each r as resource}
<div class="mt-2">
<Badge color="red">{id}</Badge> is missing a resource of type{' '}
<Badge color="red">{resource?.type}</Badge> for the input{' '}
<Badge color="red">{resource?.argName}</Badge>
</div>
{/each}
{/each}
</Alert>
{/if}
{#if steps.length == 0}
<div class="mt-2"></div>
{#if flowStore.val.value.modules.length == 0}
<Alert type="warning" title="No steps" class="m-4">
This flow has no steps. Add a step to see its static inputs.
</Alert>
{:else}
<Alert type="warning" title="No static inputs" class="m-4">
This flow has no steps with static inputs. Add a step with static inputs to see them
here.
</Alert>
{/if}
{/if}
{#each steps as [_args, filter, m], index (m.id + index)}
{#if filter.length > 0}
<div class="relative h-full border-t p-4">
<h2 class="sticky w-full top-0 z-10 inline-flex items-center py-2">
<span class="mr-4">{m.summary || m.value['path'] || 'Inline script'}</span>
<Badge large color="indigo">{m.id}</Badge>
</h2>
<Alert type="info" title="Environment Variables" class="m-4">
Define environment variables that can be referenced throughout your flow using the
<code class="text-xs">env</code> prefix (e.g., <code class="text-xs">env.FOO</code>).
These variables are stored in the flow definition and can be easily updated when deploying
or forking the flow.
</Alert>
<InputTransformSchemaForm
noDynamicToggle
{filter}
class="mt-2"
schema={flowStateStore.val[m.id]?.schema ?? {}}
bind:args={steps[index][0]}
/>
</div>
{/if}
{/each}
{#if envEntries.length === 0}
<Alert type="warning" title="No environment variables" class="m-4">
This flow has no environment variables defined. Click "Add Variable" to create one.
</Alert>
{:else}
<div class="p-4 space-y-3">
{#each envEntries as [key, value], index (key + index)}
<div class="flex items-center gap-2 p-3 border rounded bg-surface-secondary">
<div class="flex-1 grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-secondary mb-1 block">Variable Name</label>
<input
type="text"
class="windmill-input"
placeholder="VARIABLE_NAME"
value={key}
oninput={(e) => {
const target = e.currentTarget as HTMLInputElement
const newKey = target.value
if (newKey && newKey !== key) {
updateEnvVariableKey(key, newKey)
}
}}
/>
</div>
<div>
<label class="text-xs text-secondary mb-1 block">Value</label>
<input
type="text"
class="windmill-input"
placeholder="value"
value={value}
oninput={(e) => {
const target = e.currentTarget as HTMLInputElement
updateEnvVariableValue(key, target.value)
}}
/>
</div>
</div>
<Button
size="xs"
color="red"
variant="border"
startIcon={{ icon: Trash }}
iconOnly
onclick={() => removeEnvVariable(key)}
aria-label="Remove variable"
/>
</div>
{/each}
</div>
{/if}
</div>
</FlowCard>
</div>

View File

@@ -288,6 +288,7 @@
<PropPickerWrapper
notSelectable
flow_input={stepPropPicker.pickableProperties.flow_input}
env={flowStore.val.value.env_variables}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
parallelismEditor?.insertAtCursor(detail)
@@ -315,7 +316,8 @@
}
class="small-editor"
shouldBindKey={false}
extraLib={stepPropPicker.extraLib}
extraLib={stepPropPicker.extraLib +
`\ndeclare const env = ${JSON.stringify(flowStore.val.value.env_variables)};`}
/>
</PropPickerWrapper>
</div>
@@ -375,6 +377,7 @@
<PropPickerWrapper
alwaysOn
notSelectable
env={flowStore.val.value.env_variables}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
if ($flowPropPickerConfig?.insertionMode == CONNECT) {
@@ -400,7 +403,8 @@
bind:code={mod.value.iterator.expr}
class="small-editor"
shouldBindKey={false}
extraLib={stepPropPicker.extraLib}
extraLib={stepPropPicker.extraLib +
`\ndeclare const env = ${JSON.stringify(flowStore.val.value.env_variables)};`}
/>
</PropPickerWrapper>
</div>

View File

@@ -166,6 +166,7 @@
<PropPickerWrapper
notSelectable
flow_input={stepPropPicker.pickableProperties.flow_input}
env={flowStore.val.value.env_variables}
pickableProperties={undefined}
result={earlyStopResult}
extraResults={isLoop ? { all_iters: result } : undefined}
@@ -181,6 +182,7 @@
class="few-lines-editor"
extraLib={`declare const result = ${JSON.stringify(earlyStopResult)};` +
`\n declare const flow_input = ${JSON.stringify(stepPropPicker.pickableProperties.flow_input)};` +
`\n declare const env = ${JSON.stringify(flowStore.val.value.env_variables)};` +
(isLoop ? `\ndeclare const all_iters = ${JSON.stringify(result)};` : '')}
/>
</PropPickerWrapper>
@@ -302,6 +304,7 @@
<PropPickerWrapper
notSelectable
flow_input={stepPropPicker.pickableProperties.flow_input}
env={flowStore.val.value.env_variables}
pickableProperties={undefined}
{result}
on:select={({ detail }) => {
@@ -315,7 +318,8 @@
bind:code={flowModule.stop_after_all_iters_if.expr}
class="few-lines-editor"
extraLib={`declare const result = ${JSON.stringify(result)};` +
`\ndeclare const flow_input = ${JSON.stringify(stepPropPicker.pickableProperties.flow_input)};`}
`\ndeclare const flow_input = ${JSON.stringify(stepPropPicker.pickableProperties.flow_input)};` +
`\ndeclare const env = ${JSON.stringify(flowStore.val.value.env_variables)};`}
/>
</PropPickerWrapper>
</div>

View File

@@ -71,6 +71,7 @@
<div class="border w-full">
<PropPickerWrapper
notSelectable
env={flowStore.val.value.env_variables}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
@@ -82,7 +83,8 @@
lang="javascript"
bind:code={flowModule.skip_if.expr}
class="few-lines-editor"
extraLib={stepPropPicker.extraLib}
extraLib={stepPropPicker.extraLib +
`\ndeclare const env = ${JSON.stringify(flowStore.val.value.env_variables)};`}
/>
</PropPickerWrapper>
</div>

View File

@@ -77,6 +77,7 @@
noFlowPlugConnect={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
env={flowStore.val.value.env_variables}
{result}
displayContext={false}
pickableProperties={undefined}

View File

@@ -18,7 +18,8 @@
import EditableSchemaDrawer from '$lib/components/schema/EditableSchemaDrawer.svelte'
import AddProperty from '$lib/components/schema/AddProperty.svelte'
const { selectedId, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { selectedId, flowStateStore, flowStore } =
getContext<FlowEditorContext>('FlowEditorContext')
const result = flowStateStore.val[$selectedId]?.previewResult ?? {}
let editor: SimpleEditor | undefined = $state(undefined)
@@ -219,6 +220,7 @@
<PropPickerWrapper
{result}
noFlowPlugConnect
env={flowStore.val.value.env_variables}
displayContext={false}
pickableProperties={undefined}
on:select={({ detail }) => {

View File

@@ -83,6 +83,7 @@
<PropPickerWrapper
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
env={flowStore.val.value.env_variables}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)

View File

@@ -173,6 +173,7 @@
{#if stepPropPicker}
<PropPickerWrapper
notSelectable
env={flowStore?.val.value.env_variables}
pickableProperties={stepPropPicker.pickableProperties}
{result}
on:select={({ detail }) => {
@@ -186,7 +187,8 @@
bind:code={flowModuleRetry.retry_if.expr}
class="few-lines-editor"
extraLib={`declare const result = ${JSON.stringify(result)};` +
`\ndeclare const flow_input = ${JSON.stringify(stepPropPicker.pickableProperties.flow_input || {})};`}
`\ndeclare const flow_input = ${JSON.stringify(stepPropPicker.pickableProperties.flow_input || {})};` +
`\ndeclare const env = ${JSON.stringify(flowStore?.val.value.env_variables)};`}
/>
</PropPickerWrapper>
{:else}
@@ -195,7 +197,8 @@
lang="javascript"
bind:code={flowModuleRetry.retry_if.expr}
class="few-lines-editor"
extraLib={`declare const result = ${JSON.stringify(result)};`}
extraLib={`declare const result = ${JSON.stringify(result)};` +
`\ndeclare const env = ${JSON.stringify(flowStore?.val.value.env_variables)};`}
/>
{/if}
</div>

View File

@@ -65,7 +65,7 @@
<DollarSign size={14} />
</button>
{#snippet text()}
Static Inputs
Environment Variables
{/snippet}
</Popover>
{/if}

View File

@@ -37,6 +37,7 @@
export let result: any = undefined
export let extraResults: any = undefined
export let flow_input: any = undefined
export let env: any = undefined
export let error: boolean = false
export let displayContext = true
export let notSelectable = false
@@ -125,6 +126,7 @@
{result}
{extraResults}
{flow_input}
{env}
allowCopy={!notSelectable && !$propPickerConfig}
on:select={({ detail }) => {
dispatch('select', detail)

View File

@@ -5,6 +5,7 @@
export let result: any
export let extraResults: any = undefined
export let flow_input: any = undefined
export let env: any = undefined
</script>
<div class="w-full px-2">
@@ -14,8 +15,14 @@
</div>
{#if flow_input}
<span class="font-normal text-sm text-secondary">Flow Input</span>
<div class="overflow-y-auto w-full">
<div class="overflow-y-auto mb-2 w-full">
<ObjectViewer {allowCopy} json={flow_input} prefix="flow_input" on:select />
</div>
{/if}
{#if env}
<span class="font-normal text-sm text-secondary">Environment Variables</span>
<div class="overflow-y-auto w-full">
<ObjectViewer {allowCopy} json={env} prefix="env" on:select />
</div>
{/if}
</div>

View File

@@ -65,6 +65,11 @@ components:
chat_input_enabled:
type: boolean
description: Whether this flow accepts chat-style input
env_variables:
type: object
additionalProperties:
type: string
description: Environment variables for the flow
required:
- modules