Files
windmill/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts
T
7a29abc7ee feat: run a workspace handler when a variable's value expires (#10837)
* feat: run a workspace handler when a secret variable expires

Secret variables gain an optional `value_expires_at`, and a workspace can point
at a script or flow to run once that date passes, so a rotation is driven by the
platform instead of tracked out of band.

The secret never leaves the database. The sweep selects the path, description and
expiry and nothing else, so the handler is given a reference to rotate rather than
a value to handle. Job results are stored in cleartext and shown in Runs, which is
what makes that a boundary rather than a preference; a test asserts the job's args
never contain the value.

Each row is claimed by writing `expiration_dispatched_for` in the same transaction
as the push, so a failed push un-claims it and a lost race claims nothing. Moving
the date re-arms the row; re-saving the same date does not. A pass takes at most
100 rows under a transaction-scoped advisory lock, and orders so that one broken
workspace cannot starve the rest.

Dispatch runs as `g/variable_expiration_handler`, exempt from the CE group limit
and from non-premium exec metering the way the error and schedule handlers already
are. A handler that keeps failing backs off and raises a critical alert instead of
retrying every pass, and the back-off ends when the handler is repointed.

The expiry travels with the variable through workspace clone, tarball export and
workspace comparison. The dispatch marker deliberately does not, so a fork re-arms
on its own copy.

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

* 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>

* feat(cli): sync variable expiry and the workspace expiration handler

`wmill sync` carries `value_expires_at` on variables in both directions, and
pushes the workspace handler alongside the error and success handlers in the
grouped settings format. Dropping the key from a variable's yaml clears the
remote expiry, so the file stays canonical rather than accumulating fields that
can only ever be added.

The handler follows the same rule, with the caveat recorded where the type is
declared: an omitted handler only reaches the clear when some other setting
differs, because the push short-circuits on a comparison that walks the local
object's keys and so never sees a missing one. That gate is shared with the two
existing handlers and is left as it is here.

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

* fix(frontend): open the variable expiration handler template in a new tab

The "Create a handler" button hands its script template to /scripts/add through
the URL hash, which `load` reads from `window.location`. A client-side navigation
leaves the previous page's hash in place, so the payload was dropped and the
editor opened on the default template at a `u/` path. `target="_blank"` forces a
full page load, matching the success handler's "Create from template" button.

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

* fix(frontend): make the variable expiry field state its own status

The expiry field rendered a DateTimeInput whose time defaults to 12:00 when it
holds no value, so a variable with no expiry looked like one already configured,
and "cleared" looked identical to "never set". Setting an expiry is optional, so
the field has to say which it is.

Rebuild it on the shape ScriptAdvancedSettings already uses for Timeout, Cache and
Concurrency limits: a toggle whose checked state is the stored value being present,
seeding a date on enable rather than revealing an empty picker, with the input
under its own label. Drop `clearable` with it, so the toggle is the single way to
mean "no expiry" instead of competing with an X.

Stop the surrounding copy promising a run that may not happen. A handler is only
dispatched if the workspace has one configured and does not skip u/ paths, so the
list tooltip now states the date alone, and the form hedges both conditions,
naming the u/ mute only on personal paths where it can apply.

Also anchor the mute toggle's tooltip: Popover's trigger is a span around a
block-level label, which stretched to the full width of its SettingCard and left
the popup floating beside the card rather than under the control.

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

* fix(rust-client): initialize value_expires_at in the variable models

The generated `CreateVariable` / `EditVariable` structs gain the field from
openapi.yaml, and client.rs builds both with exhaustive literals.

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

* fix(frontend): drop extra_args when the variable expiration handler is cleared

Clearing the handler nulls the whole setting server-side, extra_args included,
but the tab kept its loaded copy. A handler picked afterwards without reloading
was persisted with the cleared one's arguments, which the tab does not display.

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

* feat: back the variable expiration handler's built-in Slack and Teams destinations

Backfills the Slack bot token grant for g/variable_expiration_handler, mirroring
20231016142659 for g/error_handler: the slack_bot folder's extra_perms is written
once at Slack connect and never refreshed, and disconnecting leaves the folder
behind, so workspaces that already connected Slack cannot pick the grant up from
the creation-time list alone.

Adds run_variable_expiration_test_job so an admin can exercise the handler on
demand rather than waiting for a real expiry, which may be months away. It goes
through push_variable_expiration_handler so the test run proves what matters:
the handler resolves, and the group can read what it needs, under the same
identity and argument shape the sweep uses.

Also covers extra_args on the edit endpoint, which the settings tab now writes
for the built-ins' channel. The endpoint rebuilds the setting from the request,
so an omitted extra_args erases it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Do7wJTnL6MG6ngFpCGW4DG

* feat(frontend): offer Slack and Teams as variable expiration handlers

Gives the variable expiration tab the same no-code destinations the workspace
error handler has, so "tell #ops before this credential expires" no longer
requires writing a script.

A sibling component rather than a fourth mode of ErrorOrRecoveryHandler: that
one is shared with the workspace error handler and the three schedule handlers,
and its stripped-args list, hub path suffixes and Email tab are all
error-shaped. This reuses the pieces that are already generic
(SlackConnectionStatus, TeamsConnectionStatus, ChannelSelector, ScriptPicker,
SchemaForm) and leaves those five call sites untouched.

extra_args stops being an API/CLI-only escape hatch now that the built-ins write
their channel into it, so it joins the unsaved-changes comparison and the
discard path, normalised through the existing helper that drops the two
auto-managed keys.

The two hub ids are placeholders until the scripts are pushed to the hub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Do7wJTnL6MG6ngFpCGW4DG

* fix(frontend): scope the handler identity notice to the chosen destination

The notice told every admin to grant g/variable_expiration_handler write access
to the variables it rotates. That is right for a custom handler and wrong for
the built-ins, which only post a message and are granted the Slack bot token by
migration. Following it for a Slack handler would hand a group write access to
the workspace's secrets for no reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Do7wJTnL6MG6ngFpCGW4DG

* feat(frontend): flag the Slack and Teams handlers as EE

Connecting a workspace to Slack or Teams is EE-only (oauth2_oss.rs returns
"Slack only available on enterprise"), so on CE those two tabs can never be
completed. Says so up front, the way the error/success handler tab does,
instead of leaving the user at a "not connected" badge with no working path.

Only on those two tabs: a custom script or flow handler is CE.

Drops the "Connect Slack first" alert, now that the banner and the connection
badge above it already say it twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Do7wJTnL6MG6ngFpCGW4DG

* chore(backend): regenerate the sqlx cache after the merge

The merged workspace_settings column list in change_workspace_id is a new
query text, so it needs a new cached entry. Drops the two pre-merge entries
it supersedes; both had been left with conflict markers by the merge, since
rename/rename markers are eight characters and escape the usual scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Do7wJTnL6MG6ngFpCGW4DG

* fix(cli): narrow the expiry-clear check to a variable that exists remotely

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Do7wJTnL6MG6ngFpCGW4DG

* feat(frontend): point the built-in handlers at their published hub scripts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(backend): regenerate the sqlx cache after the merge

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): disable the Slack and Teams handler controls without a license

Matches the workspace error handler, which greys out its built-in destinations
and hides the test button on an unlicensed instance. The custom destination stays
editable: unlike the error handler, the variable expiration handler itself is not
an EE feature.

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

* feat: record which destination a variable expiration handler uses

handler_configured now counts slack, teams, custom or cleared instead of
set or cleared, so usage shows whether the built-in destinations are picked.

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

* fix(frontend): let the variable expiration handler template run on any worker

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

* fix: guard the variable expiration test endpoint and correct its help text

* fix: reuse the error handler's slack arg and built-in path helpers

* docs: trim variable expiration sweep and backfill comments

* feat: put the variable value expiration under an advanced section

* fix: restrict the expiration test job to built-ins and keep expiry on conflict reload

* fix: link the expiry hint to the variable's workspace and document built-in test paths

* fix: give the expiration sweep its own lock id and reuse double_option

* fix: pin the variable expiration presets so the hub-paths test accepts them

* feat: let the variable expiration handler set how far ahead it runs

* fix: give the notify before expiry setting its own card

* feat: remind the variable expiration handler at a set interval

* fix: tighten the gap between the Slack channel field and its test button

* feat: refuse a value expiry on variables linked to an OAuth account

* refactor: reuse the shared handler form for variable expiration

* refactor: sweep expiring variables round-robin and check the handler exists

* chore: trim comments to the constraints they record

* fix: compare expiry dates by moment when deploying a variable draft

* refactor: rename value_expires_at to value_valid_until

* fix: neutralize variable fields in expiration handler args

* fix: reserve the variable_expiration_handler group name for admins

* fix: send first expiry notices before reminders

* test: pin that a handler failing to start leaves its rows unclaimed

* chore: say any hub handler path skips the existence check

* fix: empty a pre-existing handler group before granting it the Slack token

* docs: correct the registered feature usage counts

* fix: let a workspace whose handler failed to start sit out for an hour

* fix: send first notices before reminders across workspaces

* fix: end a failed handler's wait as soon as it is redeployed

* fix: show an expiry-only variable change in the AI fork diff

* chore: regenerate workspace tool schemas

* test: keep one variable expiration guard per behavior

* fix: use the renamed hub expiration handlers and template

* feat: carry the variable expiry in windmill-utils-internal 1.8.4

* fix: keep the variable expiry through MCP drafts

* chore: bump windmill-utils-internal to 1.8.4

* fix: keep the variable expiry through mcp deploys

* fix: disable saving a slack or teams expiry handler without ee

* fix: skip the failure mark when the expiry handler was saved mid-sweep

* fix: count expiry telemetry only where the expiry changed

* test: assert the exact variable expiry in export and cli push tests

* fix: stamp an expiry handler failure with the pass's read time

* fix: wait for a pending handler lock instead of marking it failed

* test: isolate expiry handler tests from the deployed-script cache

* fix: retry a failing expiry handler every pass instead of waiting an hour

* chore: update ee-repo-ref to b6e23d229966cf0019c0a1926a254fda658d8def

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

Previous ee-repo-ref: c52c06d145b13a6fb04459e85ca5818bf3c1bff4

New ee-repo-ref: b6e23d229966cf0019c0a1926a254fda658d8def

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
2026-10-23 18:12:55 +02:00

692 lines
22 KiB
TypeScript

import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
import { DraftService } from '$lib/gen'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { UserDraft, type UserDraftEntry, type UserDraftItemKind } from '$lib/userDraft.svelte'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import {
getWorkspaceItemKey,
type AppDraftValue,
type ResourceDraftState,
type TriggerKind,
type TriggerRequestBody,
type VariableDraftState,
type WorkspaceItem,
type WorkspaceItemType
} from './workspaceItems'
const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = {
http: 'trigger_http',
websocket: 'trigger_websocket',
kafka: 'trigger_kafka',
nats: 'trigger_nats',
postgres: 'trigger_postgres',
mqtt: 'trigger_mqtt',
amqp: 'trigger_amqp',
sqs: 'trigger_sqs',
gcp: 'trigger_gcp',
azure: 'trigger_azure',
email: 'trigger_email'
} as const satisfies Record<TriggerKind, UserDraftItemKind>
const TRIGGER_KIND_BY_DRAFT_KIND = Object.fromEntries(
Object.entries(TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND).map(([triggerKind, draftKind]) => [
draftKind,
triggerKind
])
) as Partial<Record<UserDraftItemKind, TriggerKind>>
const GLOBAL_DRAFT_KINDS = [
'script',
'flow',
'raw_app',
'trigger_schedule',
'trigger_http',
'trigger_websocket',
'trigger_kafka',
'trigger_nats',
'trigger_postgres',
'trigger_mqtt',
'trigger_amqp',
'trigger_sqs',
'trigger_gcp',
'trigger_azure',
'trigger_email',
'resource',
'variable'
] as const satisfies UserDraftItemKind[]
function clone<T>(value: T): T {
return structuredClone(value) as T
}
function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue {
return {
summary: value.summary,
files: { ...(value.files ?? {}) },
runnables: { ...(value.runnables ?? {}) },
data: value.data ?? { ...DEFAULT_RAW_APP_DATA },
policy: value.policy === undefined ? undefined : clone(value.policy),
custom_path: value.custom_path,
// Carry the fork-base version through the whitelist — it is dropped on every
// save otherwise, which would defeat the stale-draft check.
parent_version: value.parent_version,
// Same for the friendly path of a draft-only app: dropping it here would
// rename the app back to its `draft_<uuid>` storage key on every chat edit.
draft_path: value.draft_path
}
}
function getItemSummary(value: unknown): string | undefined {
return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined
}
export function itemKindFor(
type: WorkspaceItemType,
triggerKind?: TriggerKind
): UserDraftItemKind | undefined {
switch (type) {
case 'script':
case 'flow':
case 'resource':
case 'variable':
return type
case 'app':
return 'raw_app'
case 'schedule':
return 'trigger_schedule'
case 'trigger':
return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined
}
}
export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind {
return TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[kind]
}
/** Inverse of `itemKindFor`: the chat-facing type (+ trigger kind) for a draft
* kind. Classic and raw app drafts both surface as the chat's `app` type —
* mirroring the read path, which pairs the two kinds. `undefined` for kinds
* the chat cannot address (webhook / poll / cli trigger drafts, data
* pipelines). */
export function itemTypeForKind(
kind: UserDraftItemKind
): { type: WorkspaceItemType; triggerKind?: TriggerKind } | undefined {
switch (kind) {
case 'script':
case 'flow':
case 'resource':
case 'variable':
return { type: kind }
case 'app':
case 'raw_app':
return { type: 'app' }
case 'trigger_schedule':
return { type: 'schedule' }
default: {
const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[kind]
return triggerKind ? { type: 'trigger', triggerKind } : undefined
}
}
}
function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem {
return {
type: 'script',
path,
// The session editor parks a rename in the draft's `draft_path` (see
// sessionDraftCodecs.ts); surface it so lists/pickers show the friendly
// name instead of the `draft_<uuid>` storage key.
draftPath: (draft as NewScript & { draft_path?: string }).draft_path,
summary: draft.summary,
language: draft.language,
value: draft.content,
schema: draft.schema,
parentHash: draft.parent_hash,
isDraft: true
}
}
function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem {
return {
type: 'flow',
path,
draftPath: (draft as Flow & { draft_path?: string }).draft_path,
summary: draft.summary,
// The persisted flow draft carries `version_id` (the deployed head it was
// forked from, pinned at fork by writeDraft/the editor) — the flow analog
// of a script's parent_hash.
parentVersionId: draft.version_id,
value: {
value: draft.value,
schema: draft.schema ?? null,
groups: draft.value.groups ?? null,
description: draft.description ?? null
},
isDraft: true
}
}
function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceItem {
const value = normalizeAppDraftValue(draft)
return {
type: 'app',
path,
draftPath: value.draft_path,
summary: value.summary,
parentVersionId: value.parent_version,
value,
// The chat only ever addresses the `raw_app` draft kind (see itemKindFor),
// so every app draft it can see is a code app.
rawApp: true,
isDraft: true
}
}
function scheduleDraftToWorkspaceItem(path: string, draft: NewSchedule): WorkspaceItem {
return {
type: 'schedule',
path,
summary: draft.summary ?? undefined,
value: clone(draft),
isDraft: true
}
}
function triggerDraftToWorkspaceItem(
kind: TriggerKind,
path: string,
draft: TriggerRequestBody
): WorkspaceItem {
return {
type: 'trigger',
triggerKind: kind,
path,
summary: getItemSummary(draft),
value: clone(draft),
isDraft: true
}
}
function resourceDraftToWorkspaceItem(path: string, draft: ResourceDraftState): WorkspaceItem {
return {
type: 'resource',
path,
summary: draft.description || undefined,
value: {
path,
value: clone(draft.args),
description: draft.description,
resource_type: draft.resource_type ?? '',
labels: draft.labels,
ws_specific: draft.wsSpecific
},
isDraft: true
}
}
function variableDraftToWorkspaceItem(path: string, draft: VariableDraftState): WorkspaceItem {
return {
type: 'variable',
path,
summary: draft.variable.description || undefined,
isSecret: draft.variable.is_secret,
value: {
path,
value: draft.variable.value,
is_secret: draft.variable.is_secret,
description: draft.variable.description,
account: draft.account,
is_oauth: draft.is_oauth,
expires_at: draft.expires_at,
value_valid_until: draft.value_valid_until,
labels: draft.labels,
ws_specific: draft.wsSpecific
},
isDraft: true
}
}
function userDraftEntryToWorkspaceItem(
entry: UserDraftEntry,
path = entry.path,
isLiveDraft = false
): WorkspaceItem | undefined {
let item: WorkspaceItem | undefined
switch (entry.itemKind) {
case 'script':
item = scriptDraftToWorkspaceItem(path, entry.value as NewScript)
break
case 'flow':
item = flowDraftToWorkspaceItem(path, entry.value as Flow)
break
case 'raw_app':
item = appDraftToWorkspaceItem(path, entry.value as AppDraftValue)
break
case 'trigger_schedule':
item = scheduleDraftToWorkspaceItem(path, entry.value as NewSchedule)
break
case 'resource':
item = resourceDraftToWorkspaceItem(path, entry.value as ResourceDraftState)
break
case 'variable':
item = variableDraftToWorkspaceItem(path, entry.value as VariableDraftState)
break
default: {
const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind]
item = triggerKind
? triggerDraftToWorkspaceItem(triggerKind, path, entry.value as TriggerRequestBody)
: undefined
}
}
if (!item) return undefined
// Drop a draftPath that just repeats `path` (no extra display information),
// keep it otherwise — including on live entries: a live editor that
// registers its storage key as the effective path (flow/raw-app renames
// live in the value's `draft_path`, not `path`) must not hide the staged
// rename from lists and pickers.
const draftPath = item.draftPath === item.path ? undefined : item.draftPath
return isLiveDraft ? { ...item, draftPath, isLiveDraft: true } : { ...item, draftPath }
}
function liveDisplayPath(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string
): { displayPath: string; isLiveDraft: boolean } {
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (liveDraft?.storagePath !== storagePath) {
return { displayPath: storagePath, isLiveDraft: false }
}
return {
displayPath: liveDraft.effectivePath || storagePath,
isLiveDraft: true
}
}
function resolveDraftStoragePath(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): string {
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (!liveDraft) return path
if (path === liveDraft.storagePath || path === liveDraft.effectivePath)
return liveDraft.storagePath
return path
}
export function getGlobalDraftStoragePath(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
const itemKind = itemKindFor(type, triggerKind)
return itemKind ? resolveDraftStoragePath(workspace, itemKind, path) : path
}
function getGlobalDraftSlot(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
) {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const draft = UserDraft.get(itemKind, storagePath, { workspace })
if (draft === undefined) return undefined
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
const entry = {
workspace,
itemKind,
path: storagePath,
value: draft,
meta: {},
persisted: false,
live: false
}
const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
if (!item) return undefined
return { itemKind, storagePath, displayPath, item }
}
// Current user's persisted draft value (+ records the sync baseline so a later
// save detects external conflicts). undefined when no draft exists at that path.
// Uses `getOwnDraft` (not `getDraftForUser`): the latter rejects drawer kinds
// (schedule/trigger/resource/variable drafts are private to their owner), which
// would make those drafts write-only here — listed but never readable/deployable.
// Errors (403/500/network) MUST propagate: swallowing one would make the write
// merge fall through to the deployed item instead of the user's in-progress
// draft, silently overwriting their draft-only changes.
async function fetchBackendDraftValue(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string
): Promise<unknown | undefined> {
const resp = await DraftService.getOwnDraft({
workspace,
kind: itemKind,
path: storagePath
})
if (!resp) return undefined
UserDraftDbSyncer.recordRemoteSync({ workspace, itemKind, path: storagePath }, resp.created_at)
return resp.value ?? undefined
}
// Draft VALUE for a write merge: cell-if-present (the user's freshest in-tab
// edits) else the current user's backend draft.
export async function readGlobalDraftValue<V>(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): Promise<V | undefined> {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const cell = UserDraft.get<V>(itemKind, storagePath, { workspace })
if (cell !== undefined) return cell
return (await fetchBackendDraftValue(workspace, itemKind, storagePath)) as V | undefined
}
// `itemKind` + `storagePath` are the canonical identity of the persisted draft
// (NOT item.path, which is the friendly display path). Callers use them to record
// the chat's modified-items mask.
export type DraftPersistResult =
| { status: 'saved'; item: WorkspaceItem; itemKind: UserDraftItemKind; storagePath: string }
| {
status: 'conflict'
item: WorkspaceItem
itemKind: UserDraftItemKind
storagePath: string
serverTimestamp?: string
}
| {
status: 'error'
item: WorkspaceItem
itemKind: UserDraftItemKind
storagePath: string
message: string
}
// Persist a built draft value. `UserDraft.seed` reflects it into an open editor's
// cell WITHOUT a double-POST (no-ops if no cell; its seedNextWrite suppresses the
// cell's autosave mirror), then the awaited immediate save is the single source of
// persistence + conflict detection against the shared baseline. force overwrites.
export async function persistGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
value: unknown,
opts: { triggerKind?: TriggerKind; force?: boolean } = {}
): Promise<DraftPersistResult> {
const itemKind = itemKindFor(type, opts.triggerKind)
if (!itemKind) throw new Error(`Unsupported draft type "${type}".`)
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
UserDraft.seed(itemKind, storagePath, value, { workspace })
await UserDraftDbSyncer.save({
workspace,
itemKind,
path: storagePath,
value,
immediate: true,
force: opts.force
})
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
const item = userDraftEntryToWorkspaceItem(
{ workspace, itemKind, path: storagePath, value },
displayPath,
isLiveDraft
)
if (!item) throw new Error(`Could not synthesize ${type} draft "${path}".`)
// A failed save (network/5xx) is recorded in the syncer's failure map, not
// thrown — so check it before reporting success, else a write tool would tell
// the chat "saved" while the DB-backed source of truth was never updated.
const saveState = UserDraftDbSyncer.getState({ workspace, itemKind, path: storagePath })
if (saveState.state === 'failed') {
return {
status: 'error',
item,
itemKind,
storagePath,
message: saveState.failureMessage ?? 'Draft save failed'
}
}
const conflict = opts.force
? undefined
: UserDraftDbSyncer.getConflict({ workspace, itemKind, path: storagePath }).conflict
if (conflict) {
return {
status: 'conflict',
item,
itemKind,
storagePath,
serverTimestamp: conflict.serverTimestamp
}
}
invalidateWorkspaceDrafts(workspace)
return { status: 'saved', item, itemKind, storagePath }
}
export async function getGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): Promise<WorkspaceItem | undefined> {
const slot = getGlobalDraftSlot(workspace, type, path, triggerKind)
if (slot) return slot.item
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const value = await fetchBackendDraftValue(workspace, itemKind, storagePath)
if (value === undefined || value === null) return undefined
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
return userDraftEntryToWorkspaceItem(
{ workspace, itemKind, path: storagePath, value },
displayPath,
isLiveDraft
)
}
// Maps a backend `listDrafts` metadata row (no value) to a lightweight item.
// The row's `path` is the storage path; remap it to the live editor's effective
// path (and flag it) when one is open on this key, matching the cell path.
function backendDraftRowToWorkspaceItem(
workspace: string,
row: {
kind: string
path: string
summary?: string
draft_path?: string
}
): WorkspaceItem | undefined {
if (!(GLOBAL_DRAFT_KINDS as readonly string[]).includes(row.kind)) return undefined
let type: WorkspaceItemType
let triggerKind: TriggerKind | undefined
switch (row.kind) {
case 'script':
case 'flow':
case 'resource':
case 'variable':
type = row.kind
break
case 'raw_app':
type = 'app'
break
case 'trigger_schedule':
type = 'schedule'
break
default: {
const tk = TRIGGER_KIND_BY_DRAFT_KIND[row.kind as UserDraftItemKind]
if (!tk) return undefined
type = 'trigger'
triggerKind = tk
}
}
const { displayPath, isLiveDraft } = liveDisplayPath(
workspace,
row.kind as UserDraftItemKind,
row.path
)
return {
type,
path: displayPath,
// The row's friendly path (from the draft JSON) names the item; only a
// draft_path that repeats the display path adds nothing. Kept even for
// live rows — a live registration whose effective path is the storage key
// (flow/raw-app renames live in the value's `draft_path`, not `path`)
// must not hide the staged rename.
draftPath: row.draft_path === displayPath ? undefined : row.draft_path,
summary: row.summary,
value: undefined,
isDraft: true,
triggerKind,
rawApp: row.kind === 'raw_app' ? true : undefined,
...(isLiveDraft ? { isLiveDraft: true } : {})
}
}
export async function listGlobalDrafts(workspace: string): Promise<WorkspaceItem[]> {
const drafts = new Map<string, WorkspaceItem>()
const rows = await DraftService.listDrafts({ workspace })
for (const row of rows) {
const item = backendDraftRowToWorkspaceItem(workspace, row)
if (!item) continue
drafts.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item)
}
// Overlay live in-tab cells (full values + the user's live edits); cell wins.
for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path)
const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
if (!draft) continue
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
}
return Array.from(drafts.values())
}
export async function saveGlobalAppDraft(
workspace: string,
path: string,
value: AppDraftValue
): Promise<DraftPersistResult> {
// Return the full result (not just the item) so app write tools surface a
// conflict / save failure instead of reporting every stale write as saved.
return persistGlobalDraft(workspace, 'app', path, normalizeAppDraftValue(value), {})
}
type DeleteGlobalDraftOptions = {
preserveLiveDraft?: boolean
}
export async function deleteGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind,
options: DeleteGlobalDraftOptions = {}
): Promise<void> {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) {
UserDraft.remove(itemKind, storagePath, { workspace })
} else {
UserDraft.clear(itemKind, storagePath, { workspace })
}
// `remove`/`clear` only debounce the delete; persist it now so a deploy/discard
// that the caller awaits has actually cleared the server draft on return.
await UserDraftDbSyncer.save({
workspace,
itemKind,
path: storagePath,
value: null,
immediate: true
})
// A failed (network/5xx) or conflicted delete is recorded in the syncer state,
// not thrown — surface it so callers don't report the draft as removed while
// the DB-backed source of truth still has it (same guard as the write path).
const state = UserDraftDbSyncer.getState({ workspace, itemKind, path: storagePath })
if (state.state === 'failed') {
throw new Error(state.failureMessage ?? `Failed to delete draft "${path}".`)
}
if (UserDraftDbSyncer.getConflict({ workspace, itemKind, path: storagePath }).conflict) {
throw new Error(
`Draft "${path}" changed externally since you last read it; it was not removed. Re-read and retry.`
)
}
invalidateWorkspaceDrafts(workspace)
}
/** Kind-addressed live-editor storage resolution (friendly → storage path),
* for callers that must probe several draft kinds per chat type. */
export function resolveGlobalDraftStoragePathByKind(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): string {
return resolveDraftStoragePath(workspace, itemKind, path)
}
/** Local in-memory draft cell, kind-addressed: the chat `app` type spans two
* draft kinds (raw_app + classic app), so callers probing both address by
* kind. No backend fallback — this is the freshest state when a save is
* parked (auto-save off), failed, or conflicted; read-only callers use it
* instead of persisting. */
export function readLocalDraftCellByKind(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): unknown | undefined {
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
return UserDraft.get(itemKind, storagePath, { workspace })
}
/** Flush every parked local draft autosave for the workspace so the server
* listing reflects the latest edits — a brand-new editor draft has no server
* row until its first flush. No-ops per key when nothing is pending.
* Honors the auto-save toggle: with auto-save off, parked editor edits stay
* parked — a read-only caller must not persist what the user chose not to.
* `unflushedPaths` lists items whose latest edits did NOT reach the server
* (toggle-parked or failed save), so callers can say the listing excludes them. */
export async function flushGlobalDraftSaves(
workspace: string
): Promise<{ unflushedPaths: string[] }> {
// Classic-app editor cells live under the `app` kind, which is deliberately
// NOT in GLOBAL_DRAFT_KINDS (clearGlobalDrafts must never clear a user's
// open classic editor) — but their unflushed edits must be flushed/reported
// like every other kind.
const drafts = UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS, 'app'] })
await Promise.all(
drafts.map((draft) =>
UserDraftDbSyncer.flush(
{ workspace, itemKind: draft.itemKind, path: draft.path },
{ honorAutosaveToggle: true }
)
)
)
const unflushedPaths = drafts
.filter((draft) => {
const query = { workspace, itemKind: draft.itemKind, path: draft.path }
// A conflicted save also leaves the server without the local edits:
// the payload stays parked but the state is neither pending nor failed.
return (
UserDraftDbSyncer.hasUnsavedDisabledChanges(query) ||
UserDraftDbSyncer.getState(query).state === 'failed' ||
UserDraftDbSyncer.getConflict(query).conflict !== undefined
)
})
.map((draft) => draft.path)
return { unflushedPaths }
}
export function clearGlobalDrafts(workspace: string): void {
for (const draft of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
UserDraft.clear(draft.itemKind, draft.path, { workspace })
}
}