feat(frontend): expose variable value expiration in the UI

The variable drawer takes an optional expiry on a secret value, and the variables
list badges it, turning red once the date has passed. A workspace settings tab
picks the handler, its extra args, and whether variables under `u/` are skipped.

That mute flag is stored inside the handler setting, so the toggle stays disabled
until a handler is picked: saving it on its own would clear the setting it lives
in and still report success.

The expiry is carried as a tri-state through the draft deploy and copilot paths —
absent leaves the remote value alone, an explicit null clears it — so deploying a
draft cannot silently drop a date someone set elsewhere.

The settings tab names the one case where the configured identity does not apply:
a handler script carrying `on_behalf_of` runs as that user rather than as
`g/variable_expiration_handler`, which matters because the same tab asks the admin
to grant that group write access to the variables it rotates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-08-27 12:15:23 +02:00
co-authored by Claude Opus 5
parent 35bde36730
commit 5782757d0c
14 changed files with 432 additions and 22 deletions
@@ -287,7 +287,8 @@
path,
value: v.value ?? '',
is_secret: v.is_secret ?? false,
description: v.description ?? ''
description: v.description ?? '',
value_expires_at: v.value_expires_at ?? undefined
}
})
}
@@ -1069,8 +1069,9 @@
<li>git sync repo count (sync vs promotion mode)</li>
<li
>feature usage (counts of which product features are used, including AI provider and
model identifiers, the names of public hub scripts used, and the plan tier and quota
shown when the execution meter is opened, last 30 days)</li
model identifiers, the names of public hub scripts used, the plan tier and quota shown
when the execution meter is opened, and whether a variable whose value expired was a
secret, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -1121,8 +1122,9 @@
<li>development instance status</li>
<li
>feature usage (counts of which product features are used, including AI provider and
model identifiers, the names of public hub scripts used, and the plan tier and quota
shown when the execution meter is opened, last 30 days)</li
model identifiers, the names of public hub scripts used, the plan tier and quota shown
when the execution meter is opened, and whether a variable whose value expired was a
secret, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -26,14 +26,18 @@
import LocalDraftBanner from './LocalDraftBanner.svelte'
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
const dispatch = createEventDispatcher()
// Subset of VariableDraftState (copilot/chat/global/workspaceItems.ts) — the same
// localStorage rows back both, so a field this shape omits is dropped on save.
type VariableState = {
path: string
variable: { value: string; is_secret: boolean; description: string }
labels: string[] | undefined
wsSpecific: boolean
value_expires_at?: string
}
// The "current" workspace this editor defaults New/Edit actions to. Session
@@ -186,7 +190,8 @@
description: v.description ?? ''
},
labels: v.labels ?? undefined,
wsSpecific: v.ws_specific ?? false
wsSpecific: v.ws_specific ?? false,
value_expires_at: v.value_expires_at ?? undefined
}
// Open with the saved draft if present, else the deployed.
const s: VariableState = savedDraftState ?? deployedState
@@ -253,6 +258,12 @@
async function save(): Promise<void> {
const dirty = dirtyWorkspaces
// Counted before the loop rewrites the baselines, and once per save rather than
// once per workspace, so a multi-workspace edit is one act of setting an expiry.
const expiryEdited = dirty.some(
(ws) => initialStates[ws]?.value_expires_at !== states[ws].draft?.value_expires_at
)
const expiryNowSet = dirty.some((ws) => !!states[ws].draft?.value_expires_at)
try {
for (const ws of dirty) {
const s = states[ws].draft!
@@ -270,6 +281,11 @@
ini.variable.description != s.variable.description
? s.variable.description
: undefined,
// Tri-state: absent leaves the stored date alone, `null` clears it.
value_expires_at:
ini.value_expires_at != s.value_expires_at
? (s.value_expires_at ?? null)
: undefined,
labels: s.labels,
ws_specific: s.wsSpecific
}
@@ -282,6 +298,7 @@
value: s.variable.value,
is_secret: s.variable.is_secret,
description: s.variable.description,
value_expires_at: s.value_expires_at,
labels: s.labels,
ws_specific: s.wsSpecific
}
@@ -298,6 +315,11 @@
// it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(ws)
}
if (expiryEdited) {
logFeatureUsage('variable_expiration', 'expiry_edited', {
key: expiryNowSet ? 'set' : 'cleared'
})
}
sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`)
dispatch('create')
drawer?.closeDrawer()
@@ -350,6 +372,7 @@
bind:variable={current.variable}
bind:labels={current.labels}
bind:wsSpecific={current.wsSpecific}
bind:valueExpiresAt={current.value_expires_at}
{initialPath}
deployTo={deployTo.current}
{can_write}
@@ -14,6 +14,8 @@
import { isOwner } from '$lib/utils'
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
import EncryptedDraftField from './EncryptedDraftField.svelte'
import DateTimeInput from './DateTimeInput.svelte'
import { base } from '$lib/base'
interface Variable {
value: string
@@ -28,6 +30,7 @@
variable: Variable
labels: string[] | undefined
wsSpecific: boolean
valueExpiresAt: string | undefined
deployTo: string | undefined
can_write: boolean
edit: boolean
@@ -43,6 +46,7 @@
variable = $bindable(),
labels = $bindable(),
wsSpecific = $bindable(),
valueExpiresAt = $bindable(),
deployTo,
can_write,
edit,
@@ -204,3 +208,29 @@
<textarea rows="4" use:autosize bind:value={variable.description} placeholder="Used for X"
></textarea>
</label>
<Label
label="Value expiration"
tooltip="When the value stored here stops working, such as the expiry date of an API key. Windmill runs the workspace's variable expiration handler an hour before it so the value can be rotated. The variable itself is never deleted."
>
<!-- Unbound: DateTimeInput writes `null` on clear, and a `null` where the deployed
baseline has nothing reads as an edit, so every empty state has to normalize to
`undefined` before it reaches the draft row. -->
<DateTimeInput
value={valueExpiresAt}
clearable
useDropdown
disabled={!can_write}
on:change={(e) => (valueExpiresAt = e.detail ?? undefined)}
on:clear={() => (valueExpiresAt = undefined)}
/>
<span class="text-2xs font-normal text-hint">
{#if valueExpiresAt && new Date(valueExpiresAt).getTime() <= Date.now()}
This date has passed. Set a new one once the value is rotated.
{:else}
Runs the workspace's <a href="{base}/workspace_settings?tab=variable_expiration"
>variable expiration handler</a
> an hour before this date. Without one, nothing happens.
{/if}
</span>
</Label>
@@ -927,6 +927,42 @@ describe('global AI tools', () => {
expect(localStorageSnapshot()).not.toContain('new-secret-token')
})
// Clearing an expiry once the value has been rotated is a normal edit, and the draft
// state has no null to carry it — so an absent expiry has to deploy as an explicit
// null, which the update endpoint reads as "clear" rather than "leave alone".
it('clears a variable value expiry through to the deploy body', async () => {
vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true) // write probe
vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true) // deploy probe
vi.mocked(VariableService.getVariable).mockResolvedValueOnce({
path: 'f/secrets/api_key',
value: 'tok',
is_secret: false,
description: 'd',
value_expires_at: '2027-03-15T08:30:00Z'
} as any)
await callGlobalTool('write_variable', {
path: 'f/secrets/api_key',
value_expires_at: null
})
expect(
getBackendDraft<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
?.value_expires_at
).toBeUndefined()
await callGlobalTool('deploy_workspace_item', {
type: 'variable',
path: 'f/secrets/api_key'
})
expect(VariableService.updateVariable).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({ value_expires_at: null })
})
)
})
// '' means "no value staged", so there is nothing to create the secret with.
it('does not create a secret variable draft that stages no value', async () => {
seedBackendDraft(
@@ -648,6 +648,17 @@ const writeVariableSchema = variableRequestSchema.extend({
.string()
.optional()
.describe('The description of the variable. Omit it to keep the current description.'),
// Clearing an expiry is the normal move once a value has been rotated, and the
// generated schema admits only a timestamp — leaving an omitted field and a cleared
// one indistinguishable.
value_expires_at: z
.string()
.datetime({ offset: true })
.nullable()
.optional()
.describe(
"When the value stored in this variable stops working. Triggers the workspace's variable expiration handler one hour before it is reached; never causes the variable to be deleted. Omit it to keep the current expiry, or pass null to clear one — for instance once the value has been rotated."
),
override: draftOverrideField
})
@@ -2203,7 +2214,7 @@ function getResourceInstructions(): string {
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
- A resource draft is a workspace item: \`{ type: 'resource', path, summary?, value, isDraft }\`. \`value\` is a CreateResource body: \`{ path, value, description?, resource_type, labels? }\` where the inner \`value\` is the resource type's data shape.
- Reading a variable returns \`{ type: 'variable', path, summary?, isSecret, isDraft }\` — never its value, secret or not. \`isSecret\` tells you whether the value is encrypted.
- \`write_variable\` takes \`{ path, value?, is_secret?, description?, account?, is_oauth?, expires_at?, labels? }\`. Creating a variable needs \`value\` and \`is_secret\`; editing one needs only the fields you are changing. Omitting \`value\` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any \`value\` you did not get from the user destroys it.
- \`write_variable\` takes \`{ path, value?, is_secret?, description?, account?, is_oauth?, expires_at?, value_expires_at?, labels? }\`. Creating a variable needs \`value\` and \`is_secret\`; editing one needs only the fields you are changing. Omitting \`value\` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any \`value\` you did not get from the user destroys it.
- For secret fields in a resource value, do NOT inline the raw secret. Create a Variable first with \`is_secret: true\`, then in the resource value reference it as \`"$var:path/to/variable"\`.
- Reference formats inside resource values: \`$var:g/all/name\` (global), \`$var:u/user/name\` (user), \`$var:f/folder/name\` (folder). Reference another resource with \`$res:path/to/resource\`. These are references FROM a resource value; never store a \`$var:\` string as a variable's own value.
- When deploying drafts that depend on each other (e.g., a resource and the variables it references), deploy the variables first.
@@ -4590,7 +4601,8 @@ function variableToDraftState(variable: ListableVariable): VariableDraftState {
// reviews. Undefined drops out of the JSON instead.
account: variable.account ?? undefined,
is_oauth: variable.is_oauth ?? undefined,
expires_at: variable.expires_at ?? undefined
expires_at: variable.expires_at ?? undefined,
value_expires_at: variable.value_expires_at ?? undefined
}
}
@@ -4660,7 +4672,12 @@ function createVariableToDraftState(
wsSpecific: args.ws_specific ?? base?.wsSpecific ?? false,
account: args.account ?? base?.account,
is_oauth: args.is_oauth ?? base?.is_oauth,
expires_at: args.expires_at ?? base?.expires_at
expires_at: args.expires_at ?? base?.expires_at,
// `null` clears, an omitted field keeps what the base holds. The draft state itself
// has no null — an absent expiry is `undefined` there, so that a cleared one
// survives the JSON round-trip a stored draft goes through as an absent key.
value_expires_at:
args.value_expires_at === null ? undefined : (args.value_expires_at ?? base?.value_expires_at)
}
}
@@ -4679,10 +4696,18 @@ function buildVariableCreateRequestBody(draftValue: CreateVariable): CreateVaria
// endpoint, and a draft stores '' when it stages no new value — so omitting `value` in
// that case is what leaves the stored one untouched. A staged value is sent as-is: the
// endpoint decrypts an `$encrypted:` marker and encrypts plaintext.
function buildVariableUpdateRequestBody(
draftValue: CreateVariable
): Omit<CreateVariable, 'value'> & { value?: string } {
const { value, ...rest } = structuredClone(draftValue)
function buildVariableUpdateRequestBody(draftValue: CreateVariable): Omit<
CreateVariable,
'value' | 'value_expires_at'
> & {
value?: string
value_expires_at?: string | null
} {
const { value, value_expires_at, ...others } = structuredClone(draftValue)
// The draft is seeded from the deployed variable, so an absent expiry means this edit
// cleared it and has to be sent as an explicit null — omitting it would read as "leave
// alone". Same full-state semantics as the value below.
const rest = { ...others, value_expires_at: value_expires_at ?? null }
// A value the draft is never allowed to carry — a secret's, and an OAuth-managed one
// dropped by `variableToDraftState` — reaches here as '' whenever this edit staged
// none, so sending it would blank the stored value or wipe a rotating token.
@@ -235,6 +235,7 @@ function variableDraftToWorkspaceItem(path: string, draft: VariableDraftState):
account: draft.account,
is_oauth: draft.is_oauth,
expires_at: draft.expires_at,
value_expires_at: draft.value_expires_at,
labels: draft.labels,
ws_specific: draft.wsSpecific
},
@@ -108,6 +108,7 @@ export type VariableDraftState = {
account?: number
is_oauth?: boolean
expires_at?: string
value_expires_at?: string
}
export type WorkspaceItem = {
@@ -489,7 +489,8 @@ export const variableRequestSchema = z.object({
"description": z.string().describe("The description of the variable"),
"account": z.number().int().describe("The account identifier").optional(),
"is_oauth": z.boolean().describe("Whether the variable is an OAuth variable").optional(),
"expires_at": z.string().datetime({ offset: true }).describe("The expiration date of the variable").optional(),
"expires_at": z.string().datetime({ offset: true }).describe("When to garbage-collect the variable. Used for ephemeral secrets; the variable is deleted once this is reached. Not the value's expiry \u2014 see value_expires_at.\n").optional(),
"value_expires_at": z.string().datetime({ offset: true }).describe("When the value stored in this variable stops working. Triggers the workspace's variable expiration handler one hour before it is reached; never causes the variable to be deleted.\n").optional(),
"labels": z.array(z.string()).optional(),
"ws_specific": z.boolean().optional()
})
+12 -1
View File
@@ -153,7 +153,12 @@ export const mcpEndpointTools: EndpointTool[] = [
},
"expires_at": {
"type": "string",
"description": "The expiration date of the variable",
"description": "When to garbage-collect the variable. Used for ephemeral secrets; the variable is deleted once this is reached. Not the value's expiry \u2014 see value_expires_at.\n",
"format": "date-time"
},
"value_expires_at": {
"type": "string",
"description": "When the value stored in this variable stops working. Triggers the workspace's variable expiration handler one hour before it is reached; never causes the variable to be deleted.\n",
"format": "date-time"
},
"labels": {
@@ -241,6 +246,12 @@ export const mcpEndpointTools: EndpointTool[] = [
"type": "string",
"description": "The new description of the variable"
},
"value_expires_at": {
"type": "string",
"nullable": true,
"description": "When the value stored in this variable stops working. Omit to leave it unchanged; send null to clear it. Changing it re-arms the workspace's variable expiration handler.\n",
"format": "date-time"
},
"labels": {
"type": "array",
"items": {
+55 -2
View File
@@ -6,7 +6,7 @@ vi.mock('$lib/gen', () => ({
FlowService: { getFlowByPath: vi.fn(), createFlow: vi.fn(), updateFlow: vi.fn() },
DraftService: { deleteDraft: vi.fn() },
AppService: {},
VariableService: {},
VariableService: { getVariable: vi.fn(), createVariable: vi.fn(), updateVariable: vi.fn() },
ResourceService: {},
ScheduleService: {},
HttpTriggerService: {},
@@ -30,7 +30,7 @@ vi.mock('$lib/components/raw_apps/utils', () => ({ canonicalRawAppDiffValue: vi.
vi.mock('$lib/appDiffSides', () => ({ classicAppDraftParts: vi.fn() }))
vi.mock('$lib/utils_deployable', () => ({ TRIGGER_RUNTIME_IGNORE: [] }))
import { ScriptService, FlowService } from '$lib/gen'
import { ScriptService, FlowService, VariableService } from '$lib/gen'
// draftBaseIsStale compares a draft's base pointer against the deployed head
// of the item it was fetched with (`get_draft=true`). Shared by CompareDrafts
@@ -124,3 +124,56 @@ describe('deployDraft preserves on_behalf_of', () => {
)
})
})
// The variable deploy bodies are built field-by-field, so a field left out of them is
// dropped with no error. `value_expires_at` is additionally tri-state on update — absent
// leaves the stored date alone, null clears it — which is why it is diffed against the
// deployed row rather than sent as-is.
describe('deployDraft variable value_expires_at', () => {
beforeEach(() => vi.clearAllMocks())
const DATE = '2027-03-15T08:30:00.000Z'
function mockVariable(deployedExpiry: string | undefined, draftExpiry: string | undefined) {
vi.mocked(VariableService.getVariable).mockResolvedValueOnce({
path: 'u/admin/key',
value: 'v',
is_secret: false,
description: '',
value_expires_at: deployedExpiry,
draft: {
path: 'u/admin/key',
variable: { value: 'v', is_secret: false, description: '' },
value_expires_at: draftExpiry
}
} as any)
}
const updateBody = () =>
vi.mocked(VariableService.updateVariable).mock.calls[0][0].requestBody as any
it('sends a date the draft set on a variable that had none', async () => {
mockVariable(undefined, DATE)
await deployDraft('variable', 'u/admin/key', 'ws')
expect(updateBody().value_expires_at).toBe(DATE)
})
it('sends null when the draft cleared the deployed date', async () => {
mockVariable(DATE, undefined)
await deployDraft('variable', 'u/admin/key', 'ws')
expect(updateBody().value_expires_at).toBeNull()
})
it('omits it when the draft leaves the deployed date alone', async () => {
mockVariable(DATE, DATE)
await deployDraft('variable', 'u/admin/key', 'ws')
expect(updateBody().value_expires_at).toBeUndefined()
})
it('carries the date onto a draft-only variable', async () => {
mockVariable(undefined, DATE)
await deployDraft('variable', 'u/admin/key', 'ws', { draftOnly: true })
const body = vi.mocked(VariableService.createVariable).mock.calls[0][0].requestBody as any
expect(body.value_expires_at).toBe(DATE)
})
})
+12 -4
View File
@@ -205,14 +205,15 @@ function stripScheduleTriggerRuntime(row: any): Record<string, unknown> {
function canonicalizeDraftDiffValue(kind: DraftKind, raw: any, isDraft: boolean): unknown {
if (!raw || typeof raw !== 'object') return raw ?? {}
if (kind === 'variable') {
// draft: { variable: { value, is_secret, description }, labels, wsSpecific }
// deployed row: { value, is_secret, description, labels, ws_specific }
// draft: { variable: { value, is_secret, description }, value_expires_at, labels, wsSpecific }
// deployed row: { value, is_secret, description, value_expires_at, labels, ws_specific }
const v = isDraft ? (raw.variable ?? {}) : raw
const is_secret = !!v.is_secret
return {
value: is_secret ? '<secret>' : (v.value ?? ''),
is_secret,
description: v.description ?? '',
value_expires_at: raw.value_expires_at ?? undefined,
labels: raw.labels ?? undefined,
ws_specific: (isDraft ? raw.wsSpecific : raw.ws_specific) ?? undefined
}
@@ -549,7 +550,7 @@ export async function deployDraft(
} else if (kind === 'variable') {
const { deployed, draft: d } = splitOverlay(await OVERLAY_GETTERS.variable!(workspace, path))
// VariableEditor's `VariableState` draft shape:
// { path, variable: { value, is_secret, description }, labels?, wsSpecific }
// { path, variable: { value, is_secret, description }, value_expires_at?, labels?, wsSpecific }
if (draftOnly) {
await VariableService.createVariable({
workspace,
@@ -558,6 +559,7 @@ export async function deployDraft(
value: d.variable?.value ?? '',
is_secret: !!d.variable?.is_secret,
description: d.variable?.description ?? '',
value_expires_at: d.value_expires_at,
labels: d.labels,
ws_specific: d.wsSpecific
}
@@ -572,12 +574,18 @@ export async function deployDraft(
value: d.variable?.value === '' ? undefined : d.variable?.value,
is_secret: d.variable?.is_secret,
description: d.variable?.description,
// Tri-state: absent leaves the stored date alone, `null` clears it. Diffed
// against the deployed row because the draft carries no baseline of its own
// and an absent date in it means "no expiry", not "unchanged".
value_expires_at:
(deployed.value_expires_at ?? undefined) !== d.value_expires_at
? (d.value_expires_at ?? null)
: undefined,
labels: d.labels,
ws_specific: d.wsSpecific
}
})
}
void deployed
} else if (kind === 'resource') {
const { deployed, draft: d } = splitOverlay(await OVERLAY_GETTERS.resource!(workspace, path))
// ResourceEditor's `ResourceState` draft shape:
@@ -49,6 +49,7 @@
Shield,
Trash,
Building,
CalendarClock,
DollarSign,
EyeOff,
Circle,
@@ -396,7 +397,7 @@
</tr>
</Head>
<tbody class="divide-y">
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }}
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft, value_expires_at }}
{@const hasDraft =
getLocalDraftHint($workspaceStore, 'variable', path) ?? is_draft}
<Row>
@@ -477,6 +478,22 @@
{/snippet}
</Popover>
{/if}
{#if value_expires_at}
{@const expired = new Date(value_expires_at).getTime() <= Date.now()}
<Popover notClickable>
<CalendarClock
size={16}
class={expired ? 'text-red-600' : 'text-secondary'}
/>
{#snippet text()}
<div>
The value {expired ? 'expired' : 'expires'} on
{new Date(value_expires_at).toLocaleString()}. The workspace's
variable expiration handler runs an hour before that.
</div>
{/snippet}
</Popover>
{/if}
{#if account}
<Popover notClickable>
<RefreshCw size={16} />
@@ -184,6 +184,12 @@
let errorHandlerMutedOnCancel: boolean | undefined = $state(undefined)
let errorHandlerMutedOnUserPath: boolean | undefined = $state(undefined)
let successHandlerScriptPath: string | undefined = $state(undefined)
let variableExpirationPath: string | undefined = $state(undefined)
let variableExpirationItemKind: 'flow' | 'script' = $state('script')
let variableExpirationMutedOnUserPath: boolean = $state(false)
// Carried through saves rather than edited: `extra_args` is an API/CLI-only escape hatch
// for passing fixed arguments to the handler, and omitting it here would erase it.
let variableExpirationExtraArgs: Record<string, any> | undefined = $state(undefined)
let criticalAlertUIMuted: boolean | undefined = $state(undefined)
let initialCriticalAlertUIMuted: boolean | undefined = $state(undefined)
let publicAppRateLimitPerMinute: number | undefined = $state(undefined)
@@ -237,6 +243,11 @@
// Track initial success handler for unsaved changes detection
let initialSuccessHandlerScriptPath: string | undefined = $state(undefined)
// Track initial variable expiration handler for unsaved changes detection
let initialVariableExpirationPath: string | undefined = $state(undefined)
let initialVariableExpirationItemKind: 'flow' | 'script' = $state('script')
let initialVariableExpirationMutedOnUserPath: boolean = $state(false)
let s3ResourceSettings: S3ResourceSettings = $state({
resourceType: 's3',
resourcePath: undefined,
@@ -287,6 +298,39 @@
)
})
// The stored setting is a single prefixed path, so the kind only counts as a change
// while a path is selected — otherwise toggling script/flow on an empty picker reads
// as unsaved work that would save nothing.
function variableExpirationFullPath(kind: 'flow' | 'script', path: string | undefined) {
return path ? `${kind}/${path}` : undefined
}
// The mute flag is stored inside the handler setting, so saving with no path selected
// clears the whole setting and drops it. Gating the toggle keeps the tab from offering
// a change it cannot persist.
let variableExpirationHasHandler = $derived(
!!variableExpirationFullPath(variableExpirationItemKind, variableExpirationPath)
)
// `handlerPath`, not `path`: `hasUnsavedChanges` blanks a key of that name, so a change
// of handler would never register as one.
let hasVariableExpirationChanges = $derived.by(() => {
if (tab !== 'variable_expiration') return false
return hasUnsavedChanges(
{
handlerPath: variableExpirationFullPath(
initialVariableExpirationItemKind,
initialVariableExpirationPath
),
mutedOnUserPath: initialVariableExpirationMutedOnUserPath
},
{
handlerPath: variableExpirationFullPath(variableExpirationItemKind, variableExpirationPath),
mutedOnUserPath: variableExpirationMutedOnUserPath
}
)
})
// Derived state for checking unsaved changes in critical alert mute setting
let hasCriticalAlertMuteChanges = $derived.by(() => {
if (tab !== 'critical_alerts') return false
@@ -379,6 +423,7 @@
| 'dev_workspace'
| 'error_handler'
| 'success_handler'
| 'variable_expiration'
| 'critical_alerts'
| 'ai'
| 'windmill_data_tables'
@@ -639,6 +684,14 @@
| { path?: string; extra_args?: any }
| undefined
successHandlerScriptPath = (successHandler?.path ?? '').split('/').slice(1).join('/')
const variableExpirationHandler = settings.variable_expiration_handler
const variableExpirationHandlerPath = variableExpirationHandler?.path ?? ''
variableExpirationItemKind = variableExpirationHandlerPath
? (variableExpirationHandlerPath.split('/')[0] as 'flow' | 'script')
: 'script'
variableExpirationPath = variableExpirationHandlerPath.split('/').slice(1).join('/')
variableExpirationMutedOnUserPath = variableExpirationHandler?.muted_on_user_path ?? false
variableExpirationExtraArgs = variableExpirationHandler?.extra_args
workspaceDefaultAppPath = settings.default_app
initialWorkspaceDefaultAppPath = settings.default_app
@@ -691,6 +744,11 @@
// Store initial success handler state for unsaved changes detection
initialSuccessHandlerScriptPath = successHandlerScriptPath
// Store initial variable expiration handler state for unsaved changes detection
initialVariableExpirationPath = variableExpirationPath
initialVariableExpirationItemKind = variableExpirationItemKind
initialVariableExpirationMutedOnUserPath = variableExpirationMutedOnUserPath
loadedSettings = true
}
@@ -862,6 +920,35 @@
initialErrorHandlerMutedOnUserPath = errorHandlerMutedOnUserPath
}
async function editVariableExpirationHandler() {
const path = variableExpirationFullPath(variableExpirationItemKind, variableExpirationPath)
await WorkspaceService.editVariableExpirationHandler({
workspace: $workspaceStore!,
requestBody: {
path,
extra_args: variableExpirationExtraArgs,
muted_on_user_path: variableExpirationMutedOnUserPath
}
})
sendUserToast(
path
? `Variable expiration handler set to ${path}`
: initialVariableExpirationPath
? 'Variable expiration handler removed'
: 'Variable expiration settings saved'
)
initialVariableExpirationPath = variableExpirationPath
initialVariableExpirationItemKind = variableExpirationItemKind
initialVariableExpirationMutedOnUserPath = variableExpirationMutedOnUserPath
}
function discardVariableExpirationChanges() {
variableExpirationPath = initialVariableExpirationPath
variableExpirationItemKind = initialVariableExpirationItemKind
variableExpirationMutedOnUserPath = initialVariableExpirationMutedOnUserPath
}
async function editSuccessHandler() {
if (successHandlerScriptPath) {
await WorkspaceService.editSuccessHandler({
@@ -1116,6 +1203,23 @@
}
}
}
case 'variable_expiration':
return {
savedValue: {
handlerPath: variableExpirationFullPath(
initialVariableExpirationItemKind,
initialVariableExpirationPath
),
mutedOnUserPath: initialVariableExpirationMutedOnUserPath
},
modifiedValue: {
handlerPath: variableExpirationFullPath(
variableExpirationItemKind,
variableExpirationPath
),
mutedOnUserPath: variableExpirationMutedOnUserPath
}
}
case 'critical_alerts':
return {
savedValue: { criticalAlertUIMuted: initialCriticalAlertUIMuted },
@@ -1156,6 +1260,9 @@
discardErrorHandlerSettingsChanges()
successHandlerScriptPath = initialSuccessHandlerScriptPath
break
case 'variable_expiration':
discardVariableExpirationChanges()
break
case 'critical_alerts':
criticalAlertUIMuted = initialCriticalAlertUIMuted
break
@@ -1263,6 +1370,13 @@
aiDescription: 'Error and success handler workspace settings',
isEE: true
},
{
id: 'variable_expiration',
label: 'Variable expiration',
aiId: 'workspace-settings-variable-expiration',
aiDescription:
'Runnable executed when a variable value is about to expire, so the value can be rotated'
},
{
id: 'critical_alerts',
label: 'Critical alerts',
@@ -1988,6 +2102,93 @@ export async function main(
disabled={!$enterpriseLicense}
/>
</Section>
{:else if tab == 'variable_expiration'}
<SettingsPageHeader
title="Variable expiration handler"
description="Run a script or flow an hour before the value of a workspace variable expires, so it can be rotated before it stops working."
link="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
/>
<Section label="Handler">
<div class="flex flex-col gap-6">
<div class="flex flex-row gap-2 items-center">
<!-- Bound rather than seeded through `initialPath`: the picker keeps its own
copy of the selection, so a Discard that only reset the page state would leave
the reverted path invisible. -->
<ScriptPicker
bind:scriptPath={variableExpirationPath}
bind:itemKind={variableExpirationItemKind}
allowRefresh
allowFlow
clearable
/>
<Button
variant="default"
href={`${base}/scripts/add?lang=bun#` +
encodeState({
path: 'f/variable_expiration_handler',
summary: 'Workspace Variable Expiration Handler',
description:
'Called an hour before the value of a variable in this workspace expires',
content: `//native
// Workspace Variable Expiration Handler
// Called an hour before the value of a variable in this workspace expires, and
// again whenever that date is moved to a new one.
//
// The value itself is never passed in: job arguments and results are stored in
// cleartext and shown in Runs. Read it with the Windmill client if the rotation
// needs it, but never return or log it.
export async function main(
variable_path: string,
description: string,
value_expires_at: string,
is_secret: boolean
) {
console.log(\`\${variable_path} expires at \${value_expires_at}\`)
// Rotate the value here, then write the next expiry date back onto the
// variable — that is what re-arms this handler for the following rotation.
}
`
})}
>
Create a handler
</Button>
</div>
<SettingCard class="gap-2">
<Toggle
bind:checked={variableExpirationMutedOnUserPath}
disabled={!variableExpirationHasHandler}
options={{
right: 'Do not run the handler for variables in u/ paths',
rightTooltip: variableExpirationHasHandler
? undefined
: 'Select a handler first — this setting is stored with it.'
}}
/>
</SettingCard>
<Alert type="info" title="Runs as g/variable_expiration_handler">
The handler is executed by the automatically created group
g/variable_expiration_handler. Rotating a variable means writing to it, so grant
that group write access to the variables it is meant to rotate — it starts with
none. A script that carries an on-behalf-of identity is the exception: it runs
as that user, with their permissions rather than the group's.
</Alert>
</div>
<SettingsFooter
class="mt-6"
inline
hasUnsavedChanges={hasVariableExpirationChanges}
onSave={editVariableExpirationHandler}
onDiscard={discardVariableExpirationChanges}
saveLabel="Save variable expiration handler"
/>
</Section>
{:else if tab == 'critical_alerts'}
<SettingsPageHeader
title="Workspace critical alerts"