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)
feature adoption (counts of which flow, script, trigger and worker features your
@@ -1121,8 +1122,9 @@
development instance status
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)
feature adoption (counts of which flow, script, trigger and worker features your
diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte
index 14082bb901..1eb7cc4c1d 100644
--- a/frontend/src/lib/components/VariableEditor.svelte
+++ b/frontend/src/lib/components/VariableEditor.svelte
@@ -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 {
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}
diff --git a/frontend/src/lib/components/VariableForm.svelte b/frontend/src/lib/components/VariableForm.svelte
index 662f949957..7064e6e359 100644
--- a/frontend/src/lib/components/VariableForm.svelte
+++ b/frontend/src/lib/components/VariableForm.svelte
@@ -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 @@
+
+
diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts
index 90301f5493..018c7ffa46 100644
--- a/frontend/src/lib/components/copilot/chat/global/core.test.ts
+++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts
@@ -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('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(
diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts
index 52704e7cb8..c34f8f17b6 100644
--- a/frontend/src/lib/components/copilot/chat/global/core.ts
+++ b/frontend/src/lib/components/copilot/chat/global/core.ts
@@ -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 & { 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.
diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts
index f2c3f02431..a6c1318638 100644
--- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts
+++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts
@@ -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
},
diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts
index a67eb48ac3..8a9978ecbc 100644
--- a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts
+++ b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts
@@ -108,6 +108,7 @@ export type VariableDraftState = {
account?: number
is_oauth?: boolean
expires_at?: string
+ value_expires_at?: string
}
export type WorkspaceItem = {
diff --git a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts
index 47237a0ae1..925ccfd367 100644
--- a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts
+++ b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts
@@ -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()
})
diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts
index 3e8ae4cba0..b3c114e2ae 100644
--- a/frontend/src/lib/mcpEndpointTools.ts
+++ b/frontend/src/lib/mcpEndpointTools.ts
@@ -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": {
diff --git a/frontend/src/lib/utils_draft_deploy.test.ts b/frontend/src/lib/utils_draft_deploy.test.ts
index 9bed95917b..160130afd7 100644
--- a/frontend/src/lib/utils_draft_deploy.test.ts
+++ b/frontend/src/lib/utils_draft_deploy.test.ts
@@ -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)
+ })
+})
diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts
index b322d6adc4..0f564882fe 100644
--- a/frontend/src/lib/utils_draft_deploy.ts
+++ b/frontend/src/lib/utils_draft_deploy.ts
@@ -205,14 +205,15 @@ function stripScheduleTriggerRuntime(row: any): Record {
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 ? '' : (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:
diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte
index 0a23656514..b51ddf886d 100644
--- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte
@@ -49,6 +49,7 @@
Shield,
Trash,
Building,
+ CalendarClock,
DollarSign,
EyeOff,
Circle,
@@ -396,7 +397,7 @@
- {#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}
@@ -477,6 +478,22 @@
{/snippet}
{/if}
+ {#if value_expires_at}
+ {@const expired = new Date(value_expires_at).getTime() <= Date.now()}
+
+
+ {#snippet text()}
+
+ The value {expired ? 'expired' : 'expires'} on
+ {new Date(value_expires_at).toLocaleString()}. The workspace's
+ variable expiration handler runs an hour before that.
+
+ {/snippet}
+
+ {/if}
{#if account}
diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte
index 782da1bb2c..7165bf0055 100644
--- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte
@@ -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 | 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}
/>
+ {:else if tab == 'variable_expiration'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+