diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 375e73480e..e467b7716b 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -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 } }) } diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 62bbe1ef93..a1a92acf09 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -1069,8 +1069,9 @@
  • git sync repo count (sync vs promotion mode)
  • 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. + +
    + + +
    {:else if tab == 'critical_alerts'}