Files
windmill/frontend/src/lib/utils_draft_deploy.ts
T
GuilhemandClaude Fable 5 9739d5a2c2 feat: unified read-only diff chat tool (drafts, fork vs parent, search) (#10211)
* feat(frontend): unified `diff` chat tool with cached snapshot, fork mode, and search

One read-only global-chat tool for every comparison: drafts vs deployed
(workspace index + per-item unified patches over stable YAML), deployed
fork vs parent workspace (against="parent_workspace", sharing the fork
banner's compareWorkspaces fetch through a single-flight store), and a
literal grep over changed diff lines. Multi-file raw apps split into
per-file text patches with folder-style index children and per-file
reads. Patches are materialized once into a per-workspace cache keyed on
draft created_at / comparison ahead-behind markers and the workspace
drafts version, so repeated queries never refetch unchanged content.

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

* fix(ai_evals): teach the mock draft backend what the diff tool reads

The diff tool reads drafts through the get_draft overlay, the drafts
listing's draft_only flag, and per-row created_at change markers — none
of which the benchmark mock modelled (fixed timestamp, always
draft_only, overlay ignored), so in evals every draft looked absent and
the model looped to max turns. Mirror production: monotonic
deterministic created_at bumped per upsert, draft_only computed against
the deployed stores, and draft/no_deployed overlays on script/flow/app
reads (404-shaped not-found). Also drop the diff case's judge items
about conversation content the judge never sees — tool usage is already
enforced deterministically.

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

* fix(ai): diff tool reads unsaved editor state instead of going stale

The pre-diff flush honors the auto-save toggle (a read-only tool must
not persist parked edits), which left a gap: with auto-save off — or
after a failed save — the persisted draft the diff reads is stale, and
a brand-new editor-only draft looks absent. Item reads now detect
unflushed parked edits (hasUnsavedDisabledChanges / failed save state)
and diff the in-memory editor value directly, bypassing the snapshot
cache (it must only hold persisted state) with an explicit unsaved-
changes note; index and search modes warn which items' unsaved edits
they exclude. Local values are canonicalized onto the persisted draft
shape so they never diff noisily against the deployed side.

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

* feat(ai): invalidate diff cache the moment any draft write lands

The snapshot cache leaned on time windows (5s listing throttle, 15s
read reuse) to notice writes it didn't trigger itself — an editor
autosave landing between two diff reads could serve the pre-edit patch.
The syncer now exposes onAnySaved (fires for landed upserts AND
deletes, all keys), and the snapshot subscribes once: a landed write
marks exactly that item's patch stale and expires the listing throttle,
so the next read refetches regardless of any reuse window.

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

* fix(ai): reject the diff file arg on single-document items

Passing file for a script/flow/classic-app diff was silently ignored
and returned the whole patch — an explicit error steers the model to
call again without it. Also declares the file arg on the item handlers'
signatures it was already flowing through.

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

* fix(ai): surface empty-file additions/deletions in app diffs

An empty file appearing or disappearing produces no text patch, so the
per-file split dropped it — a draft whose only change was such a file
read "unchanged". Presence changes now keep their added/deleted entry
(patch '', 0 lines), render as "(empty file)" in summaries, and a file
read states the presence change instead of an empty window.

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

* fix(ai): include classic-app drafts in the diff index and fix ++/-- search

itemTypeForKind now maps classic `app` draft rows to the chat app type
(mirroring the read path, which already pairs app/raw_app), so their
diffs materialize in the index and search instead of reporting "not
addressable". Changed-line search is hunk-aware: `---`/`+++` file
labels only occur before the first @@ marker, so a changed source line
like `++counter` (rendered `+++counter`) now matches instead of being
mistaken for a label.

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

* fix(ai): mask every variable value in chat diffs; compare classic apps value-to-value

Variable VALUES never reach a tool result — the chat-wide invariant
read_workspace_item enforces, not just for secrets. Draft-mode diffs
mask both sides with a placeholder pair that still marks WHETHER the
value changed; fork-mode masks at fetch (and still never decrypts);
item reads carry an explicit note. The former secret-only flag is now
valueMasked.

Classic-app drafts hold the bare grid value while the deployed row
nests it beside summary/policy — diffed raw, a one-field edit read as
a whole-document rewrite. Both sides now reduce to { value } via
classicAppDraftValue (pure, unwraps legacy wrapped drafts), which also
cleans the CompareDrafts drawer for classic apps.

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

* fix(ai): honest secret-draft reporting, classic-app metadata split, glob-safe file subjects

A secret variable's sides are both masked upstream, so an empty patch
cannot prove the value is unchanged — such drafts now report
"cannot be compared; may differ" (valueUncomparable) instead of
"matches deployed", in the index and item reads.

Classic-app drafts mirror summary/draft_path into the bare grid while
the deployed row keeps summary as a column: sides now reduce to
{summary, value} via classicAppDraftParts, applied to both sides, so a
summary edit diffs as one and draft-only markers never pollute the
grid diff.

Raw-app search subjects strip the file key's leading slash so
slash-anchored globs like f/x/*.tsx match.

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

* fix(ai): classic-app local edits, comparison-relevant fork fields, conflicts as unflushed

The chat app type spans two draft kinds: item mode now flushes and
probes both raw_app and classic app keys, and the flush sweep includes
classic-app editor cells (kept out of GLOBAL_DRAFT_KINDS so
clearGlobalDrafts never clears an open classic editor).

Fork projections gain the fields the backend comparison counts that
getItemValue drops: flow schema (with a taxonomy-agnostic inline-hash
strip) and resource-type description/format_extension/is_fileset.
Folder display_name is not exposed by the API's Folder type, so it
cannot be projected.

A conflicted save leaves its payload parked with state 'none', so
index/search now count conflicts among unflushed paths and say so in
their warnings.

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

* fix(ai): staged app renames diff as path; flush classic-app cells at their real keys

A staged rename (draft_path) changes where deploy lands an app, so both
app kinds now compare `path` on both sides — a rename-only draft diffs
instead of reading "matches deployed". classicAppDraftParts returns the
staged path separately from the grid.

Item mode resolves each draft kind's own storage path and additionally
asks the listing which row owns a friendly/renamed path — a renamed
classic app's cell lives at its ORIGINAL storage path, which only the
listing knows — so pending/failed/auto-save-off edits are flushed and
probed at the real keys. The appDiffSides rationale comment is
compressed to the repo's four-line limit.

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

* fix(ai): never claim folder parity the API cannot prove

folder.display_name exists only as a DB column — no folder endpoint
returns it — so an identical projection cannot prove a fork folder
matches its parent. Fork index and item reads for folders now say the
display name is not exposed and may be what differs, instead of
"content matches parent". Exposing the field on getFolder is a backend
follow-up.

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

* fix(ai): gap-free patch pagination; forced-fresh comparisons never join older fetches

When the char backstop cut inside a patch window, the continuation
offset still pointed past the requested window — silently skipping the
undelivered lines forever. windowPatch now cuts at the last complete
line and continues exactly there (a single over-budget line is
delivered truncated and stepped past so pagination always advances).

fetchWorkspaceComparison treats an in-flight request as being as old
as its start: maxAgeMs now gates joining it, so a freshness-forced
post-mutation read (maxAgeMs 0) always issues its own fetch instead of
adopting a tally that began before the mutation, and a superseded
request can no longer clobber a newer cached result.

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

* fix(ai): generation-ordered comparison writes; path-only fork reads for enum-less kinds

Concurrent comparison requests can share a Date.now() value, letting a
superseded request's late result overwrite a newer one and be reused
for 30s — cache writes are now ordered by a monotonic request
generation. Test pins the same-millisecond race with the newer request
resolving first.

Fork comparison kinds outside the chat type enum (folder,
resource_type, …) were listed and even advertised as readable but no
call could reach them: a fork item read without `type` is now a
path-only wildcard (ambiguous paths list their kinds and ask for
type), messages label entries by their comparison kind, and pending
index lines for enum-less kinds advertise the path-only read.

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

* fix(ai): grid-based wrapper detection, comparison invalidation on mutations, multi-kind wildcard reads, honest hidden-diff summaries

The classic-app wrapper heuristic keyed on metadata keys the editor
mirrors into every bare grid — a grid with a component named `value`
was reduced to that component. `grid` presence is the discriminator: a
bare App always has it, a legacy wrapper never does.

invalidateWorkspaceDrafts now also drops cached fork comparisons for
the workspace, so the FIRST post-deploy fork read cannot reuse a
banner-prewarmed pre-deploy tally (the snapshot-baseline check only
covered subsequent reads).

Wildcard fork reads return a section per matching kind instead of an
unactionable "pass type" for kinds the chat type enum cannot name, and
the fork index never summarizes ACL-hidden differences as parity —
hidden counts stay directional (a conflicted item counts in both).

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

* fix(ai): address cubic review batch — invalidation scope, races, edge output

Comparison cache: invalidation matches either side of the pair (a
parent deploy moves its forks' tallies), fences in-flight requests
(no new joins, late results rejected via a per-key generation floor),
and the map is LRU-capped. Eviction moves from every drafts-version
bump to deploy success only — draft saves never move the deployed
tally. Fork snapshots also baseline the PARENT's drafts version.

Draft materialization carries a stale-generation token so a save
landing mid-fetch discards that run's pre-save result instead of
repopulating the invalidated entry; a save/delete also expires the
fork cache's hasLocalDraft join. onAnySaved listeners are
error-isolated (a throwing listener must not mark a committed save
failed) and the pagehide keepalive flush notifies them on dispatch.

Output edges: folder fork lines drop the empty parenthetical, and a
patch-window offset past the end reports itself instead of an
impossible range. The eval case pins the diff call's path argument.

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

* refactor(ai): one fencing primitive per cache instead of per-surface races

Review rounds kept finding pairwise races between async producers and
invalidation — each patched with its own fence. Replace the class:

- diffSnapshot: a per-workspace mutation epoch, bumped by every
  invalidation. Both reconcilers run a bounded retry loop — joiners
  re-validate after awaiting, producers refuse to store results whose
  inputs predate a mutation. Covers in-flight listing adoption and
  pre-deploy fork tallies in one mechanism.
- workspaceComparison: per-WORKSPACE generation floors (either side of
  a pair). Any request started before an invalidation is fenced from
  joining and from landing in the cache — including superseded
  requests the inflight map no longer tracks.

Also: delete_workspace_item invalidates comparisons like deploy does
(deployed state moved), and empty FILTERED indexes say the filter
matched nothing instead of claiming workspace/fork parity.

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

* fix(ai): invalidate comparisons on every direct deploy; keep secret caveat with metadata changes

Direct chat deploys (schedule/trigger/resource/variable/app) bypass
deployDraftToWorkspace and never evicted cached fork comparisons — the
shared deploy tail now invalidates before the fallible draft cleanup.

A secret variable whose metadata also changed produced a non-empty patch
that silently dropped the value-uncomparable caveat; item reads, the
index, and fork sections now keep the caveat alongside the patch.

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

* fix(ai): scope diff caches to the authenticated identity; derive fork freshness from the comparison store

An SPA logout/login left workspace-keyed diff caches (per-user drafts,
permission-filtered fork patches) readable by the next account — both
cache modules now wipe on identity change, with a global generation
floor fencing requests started under the previous account.

The fork snapshot stamped its own fetchedAt over a comparison that
could already be near expiry, compounding the two 30s windows, and
survived comparison-store invalidation when draft cleanup failed after
a deploy. It now carries the comparison's own fetchedAt/generation and
stops reuse the moment the store fences it. Delete-item invalidation
moved before the fallible draft cleanup for the same reason.

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

* fix(ai): fence fork-reconciliation joins across account switches

ForkCache lacked the epoch stamp WorkspaceCache carries, so a joiner
arriving after an identity change (or any epoch bump landing before it)
compared its own post-bump epoch against itself and adopted the old
producer's in-flight tally. The cache now records its producer's epoch
for the joiner and reuse gates, and an identity change also discards
the in-flight reconciliation maps so no cross-identity join exists.

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

* fix(ai): surface swallowed fork-side fetch failures; include conflicted editor edits in item diffs

The shared getItemValue reads {} for any failed fetch, so a transient
API failure on a fork side rendered as a fabricated one-sided diff (or
parity when both sides failed). A fork side is only fetched when the
comparison lists it as existing, so an empty read now raises and shows
as a fetch-error entry.

Item reads promised conflicted local edits (the index says so) but the
local-override branch only covered autosave-off and failed saves — a
conflict silently fell back to the persisted draft. Conflicts now read
the in-memory editor value too, with a caveat naming which side is
shown either way.

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

* fix(ai): report failed diff materializations as unsearched instead of silently omitting them

A side-fetch failure left an index entry with status 'error' and no
patch; diff search skipped it and still presented definitive no-match
or complete-count results. Failed entries are now listed in a warning
naming what was not searched.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 17:06:32 +00:00

740 lines
31 KiB
TypeScript

/**
* Draft deploy/discard orchestration for the compare page's "draft" mode.
*
* Drafts only exist for scripts, flows and apps (the `draft_type` enum). A draft
* is the editor's serialized state stored in the `draft` table; deploying it is
* the same create/update call the editor makes on "Deploy", which auto-deletes
* the matching draft server-side (unless `skip_draft_deletion`) — so we never
* call `deleteDraft` after a successful deploy. The lock/dependency job runs
* async, exactly as in the editor.
*
* Discarding branches on `draft_only`: a `draft_only` item exists only as a
* draft, so discarding deletes the whole item (mirrors `common/table/*Row.svelte`);
* a draft on an already-deployed item just deletes the draft row.
*/
import { get, writable } from 'svelte/store'
import {
DraftService,
ScriptService,
FlowService,
AppService,
VariableService,
ResourceService,
ScheduleService,
HttpTriggerService,
WebsocketTriggerService,
PostgresTriggerService,
KafkaTriggerService,
NatsTriggerService,
MqttTriggerService,
AmqpTriggerService,
SqsTriggerService,
GcpTriggerService,
AzureTriggerService,
EmailTriggerService,
type UserDraftItemKind
} from '$lib/gen'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import type { DeployResult } from '$lib/utils_workspace_deploy'
import { TRIGGER_RUNTIME_IGNORE } from '$lib/utils_deployable'
import { deployRawAppDraft } from '$lib/rawAppDeploy'
import { canonicalRawAppDiffValue } from '$lib/components/raw_apps/utils'
import { classicAppDraftParts } from '$lib/appDiffSides'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { invalidateWorkspaceComparison } from '$lib/workspaceComparison'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { userStore } from '$lib/stores'
import { deployTriggers, type Trigger } from '$lib/components/triggers/utils'
import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils'
import { saveHttpRouteFromCfg } from '$lib/components/triggers/http/utils'
import { saveWebsocketTriggerFromCfg } from '$lib/components/triggers/websocket/utils'
import { savePostgresTriggerFromCfg } from '$lib/components/triggers/postgres/utils'
import { saveKafkaTriggerFromCfg } from '$lib/components/triggers/kafka/utils'
import { saveNatsTriggerFromCfg } from '$lib/components/triggers/nats/utils'
import { saveMqttTriggerFromCfg } from '$lib/components/triggers/mqtt/utils'
import { saveAmqpTriggerFromCfg } from '$lib/components/triggers/amqp/utils'
import { saveSqsTriggerFromCfg } from '$lib/components/triggers/sqs/utils'
import { saveGcpTriggerFromCfg } from '$lib/components/triggers/gcp/utils'
import { saveAzureTriggerFromCfg } from '$lib/components/triggers/azure/utils'
import { saveEmailTriggerFromCfg } from '$lib/components/triggers/email/utils'
export type DraftKind = UserDraftItemKind
/** Kind → "get by path with draft overlay" call, for kinds whose draft is the
* editor's flat config (all but script/flow/app, handled below). Feature-gated
* trigger services 404 when the backend lacks the kind; the caller surfaces it. */
const OVERLAY_GETTERS: Partial<
Record<DraftKind, (workspace: string, path: string) => Promise<any>>
> = {
variable: (workspace, path) =>
VariableService.getVariable({ workspace, path, decryptSecret: false, getDraft: true }),
resource: (workspace, path) => ResourceService.getResource({ workspace, path, getDraft: true }),
trigger_schedule: (workspace, path) =>
ScheduleService.getSchedule({ workspace, path, getDraft: true }),
trigger_http: (workspace, path) =>
HttpTriggerService.getHttpTrigger({ workspace, path, getDraft: true }),
trigger_websocket: (workspace, path) =>
WebsocketTriggerService.getWebsocketTrigger({ workspace, path, getDraft: true }),
trigger_postgres: (workspace, path) =>
PostgresTriggerService.getPostgresTrigger({ workspace, path, getDraft: true }),
trigger_kafka: (workspace, path) =>
KafkaTriggerService.getKafkaTrigger({ workspace, path, getDraft: true }),
trigger_nats: (workspace, path) =>
NatsTriggerService.getNatsTrigger({ workspace, path, getDraft: true }),
trigger_mqtt: (workspace, path) =>
MqttTriggerService.getMqttTrigger({ workspace, path, getDraft: true }),
trigger_amqp: (workspace, path) =>
AmqpTriggerService.getAmqpTrigger({ workspace, path, getDraft: true }),
trigger_sqs: (workspace, path) =>
SqsTriggerService.getSqsTrigger({ workspace, path, getDraft: true }),
trigger_gcp: (workspace, path) =>
GcpTriggerService.getGcpTrigger({ workspace, path, getDraft: true }),
trigger_azure: (workspace, path) =>
AzureTriggerService.getAzureTrigger({ workspace, path, getDraft: true }),
trigger_email: (workspace, path) =>
EmailTriggerService.getEmailTrigger({ workspace, path, getDraft: true })
}
/** Strip the per-user draft-overlay metadata, returning `{deployed, draft}`. */
function splitOverlay(r: any): {
deployed: any
draft: any
hasDraft: boolean
noDeployed: boolean
} {
const {
draft,
is_draft: _i,
draft_saved_at: _c,
no_deployed,
other_drafts_users: _o,
...deployed
} = r
return {
deployed,
draft: draft ?? deployed,
hasDraft: draft != null,
noDeployed: no_deployed === true
}
}
export interface DraftDiffValues {
deployed: unknown
draft: unknown
/** False when the overlay carried no draft row (the item's own value was used as the draft side). */
hasDraft: boolean
/** True when the item has never been deployed (`draft_only` overlay). */
noDeployed: boolean
}
// Empty-but-valid "deployed" shapes for draft_only items. A bare `{}` breaks
// the flow graph diff (needs `value.modules`) and hangs the drawer, so each
// listed kind gets a minimal shape; unlisted kinds fall back to `{}`.
const EMPTY_DEPLOYED: Partial<Record<DraftKind, (draft: any) => unknown>> = {
script: (draft) => ({ content: '', language: draft?.language, schema: {} }),
flow: () => ({ summary: '', value: { modules: [] }, schema: {} }),
app: () => ({ summary: '', value: {} })
}
// Server-managed script-row fields, stripped from BOTH sides of a draft diff:
// never user-edited, they are either identical noise (created_at, workspace_id)
// or spuriously different (lock is recomputed at deploy). The draft-side
// pinned-base `parent_hash` is stripped separately, like the flow `version_id`.
const SCRIPT_ROW_RUNTIME_IGNORE = new Set([
'workspace_id',
'hash',
'parent_hash',
'parent_hashes',
'created_at',
'created_by',
'archived',
'deleted',
'extra_perms',
'lock',
'lock_error_logs',
'starred',
'has_draft',
'draft_only',
'assets',
'marked'
])
function stripScriptRowRuntime(row: any): Record<string, unknown> {
if (!row || typeof row !== 'object') return {}
return Object.fromEntries(Object.entries(row).filter(([k]) => !SCRIPT_ROW_RUNTIME_IGNORE.has(k)))
}
/** Canonicalize a raw draft value onto the same shape `getDraftDiffValues`
* yields for its draft side, so a value read from an in-memory editor cell
* diffs cleanly against a deployed side (and compares equal to its own
* persisted form instead of differing on stripped fields). */
export function canonicalDraftSideValue(kind: DraftKind, value: unknown): unknown {
if (kind === 'script') return stripScriptRowRuntime(value)
if (kind === 'raw_app') return canonicalRawAppDiffValue((value ?? {}) as Record<string, any>)
if (kind === 'app') {
const parts = classicAppDraftParts(value)
return { summary: parts.summary ?? '', value: parts.value }
}
if (kind === 'flow' && value !== null && typeof value === 'object') {
const { version_id: _v, ...rest } = value as Record<string, unknown>
return rest
}
// Drawer kinds (variables/resources/schedules/triggers): the editor-state
// shape diverges from the backend row — same canonicalization the overlay
// diff applies.
return canonicalizeDraftDiffValue(kind, value, true)
}
// Schedule & trigger rows drop the same runtime/server-managed fields as the
// fork/compare path so the diff shows only config changes — reuse that set
// (the authoritative mirror of the backend `TRIGGER_COMPARE_IGNORE`).
function stripScheduleTriggerRuntime(row: any): Record<string, unknown> {
if (!row || typeof row !== 'object') return {}
return Object.fromEntries(Object.entries(row).filter(([k]) => !TRIGGER_RUNTIME_IGNORE.has(k)))
}
/**
* Project a variable/resource/schedule/trigger value — given in either its
* deployed backend-row shape or its draft editor-state shape — onto one
* canonical field set, so the deployed and draft sides of a diff are comparable
* and read as labeled rows instead of structural noise (variable `variable.value`
* vs `value`, resource `args` vs `value`, schedule/trigger runtime fields). This
* matches the shaping the compare page applies via `getItemValue`. `isDraft`
* selects the editor-state field names; secret variable values are masked.
*/
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 }
const v = isDraft ? (raw.variable ?? {}) : raw
const is_secret = !!v.is_secret
return {
value: is_secret ? '<secret>' : (v.value ?? ''),
is_secret,
description: v.description ?? '',
labels: raw.labels ?? undefined,
ws_specific: (isDraft ? raw.wsSpecific : raw.ws_specific) ?? undefined
}
}
if (kind === 'resource') {
// draft: { args, description, resource_type, labels, wsSpecific }
// deployed row: { value, description, resource_type, labels, ws_specific }
return {
value: (isDraft ? raw.args : raw.value) ?? {},
description: raw.description ?? '',
resource_type: raw.resource_type ?? undefined,
labels: raw.labels ?? undefined,
ws_specific: (isDraft ? raw.wsSpecific : raw.ws_specific) ?? undefined
}
}
// schedule + triggers: same field names on both sides — drop runtime noise.
return stripScheduleTriggerRuntime(raw)
}
/**
* Fetch the deployed value and the draft value for an item, for the DiffDrawer
* (`mode: 'simple'`, original = deployed, current = draft). For a `draft_only`
* item there is no real deployed value, so the deployed side is a minimal
* empty-but-valid shape and the draft shows as entirely new. DiffDrawer cleans
* both sides via `cleanValueProperties`, so raw objects are fine here.
*/
export async function getDraftDiffValues(
kind: DraftKind,
path: string,
workspace: string,
draftOnly = false
): Promise<DraftDiffValues> {
// A `draft_only` item can keep its content in the row itself with no separate
// draft-table row (e.g. a flow created via createFlow(draft_only: true), like
// `u/admin/new`). There `draft` is null, so the draft side must fall back to
// the row's own value — otherwise the diff "after" is empty and nothing shows.
// Strip overlay metadata (is_draft / draft_saved_at / no_deployed /
// other_drafts_users) from the deployed side so the diff doesn't show the
// per-user markers as noise.
if (kind === 'script') {
const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any
const {
draft,
is_draft: _i,
draft_saved_at: _c,
no_deployed,
other_drafts_users: _o,
hash: _h,
...deployed
} = r
const draftValue = stripScriptRowRuntime(draft ?? deployed)
return {
deployed: draftOnly ? EMPTY_DEPLOYED.script!(draftValue) : stripScriptRowRuntime(deployed),
draft: draftValue,
hasDraft: draft != null,
noDeployed: no_deployed === true
}
} else if (kind === 'flow') {
const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any
const {
draft,
is_draft: _i,
draft_saved_at: _c,
no_deployed,
other_drafts_users: _o,
version_id: _v,
...deployed
} = r
// Strip the draft's pinned base `version_id` (which differs from the deployed
// head for a stale draft) so it never renders as a spurious diff line.
const { version_id: _dv, ...draftValue } = (draft ?? deployed) as any
return {
deployed: draftOnly ? EMPTY_DEPLOYED.flow!(draftValue) : deployed,
draft: draftValue,
hasDraft: draft != null,
noDeployed: no_deployed === true
}
} else if (kind === 'app' || kind === 'raw_app') {
// A never-deployed raw app has no `app` row; the backend resolves the
// draft kind from `rawApp`, so it MUST be set or the lookup 404s.
const r = (await AppService.getAppByPath({
workspace,
path,
getDraft: true,
rawApp: kind === 'raw_app'
})) as any
if (kind === 'raw_app' || r.raw_app === true) {
// Raw-app drafts are stored flat (files/runnables/data top-level) while the
// deployed row nests them under `value`, and deployed inline scripts carry
// server-recomputed locks. Canonicalize both onto the same shape with the
// post-deploy noise stripped — the same module the editor's Diff button uses.
// A staged rename (`draft_path`) changes where deploy lands the app —
// compare it as `path` on both sides so a rename-only draft diffs.
const rawDraftPath = (r.draft?.draft_path as string | undefined) ?? r.path
return {
deployed: draftOnly
? canonicalRawAppDiffValue({})
: { ...canonicalRawAppDiffValue(r), path: r.path },
draft: { ...canonicalRawAppDiffValue(r.draft ?? r), path: rawDraftPath },
hasDraft: r.draft != null,
noDeployed: r.no_deployed === true
}
}
// Classic app: the editor drafts the bare grid with summary/draft_path
// mirrored into it, while the row keeps summary as a column beside
// `value`. Both sides reduce to `{ summary, value }` with the metadata
// extracted from the grid, so a summary edit diffs as a summary edit and
// the grid never diffs against draft-only markers.
const deployedParts = classicAppDraftParts(r.value)
const draftParts = r.draft != null ? classicAppDraftParts(r.draft) : deployedParts
const deployed = { summary: r.summary ?? '', value: deployedParts.value, path: r.path }
return {
deployed: draftOnly ? EMPTY_DEPLOYED.app!(undefined) : deployed,
draft: {
summary: draftParts.summary ?? r.summary ?? '',
value: draftParts.value,
path: draftParts.draftPath ?? r.path
},
hasDraft: r.draft != null,
noDeployed: r.no_deployed === true
}
} else {
// Variables / resources / schedules / triggers: one overlay GET yields
// both sides, but the draft side is the editor's state shape while the
// deployed side is the backend row — they diverge enough to make a raw
// diff pure noise. Canonicalize both onto a shared field set (same shaping
// the compare page's `getItemValue` applies) so only real changes show.
const getter = OVERLAY_GETTERS[kind]
if (!getter) {
throw new Error(`Draft diff not supported for kind ${kind}`)
}
const { deployed, draft, hasDraft, noDeployed } = splitOverlay(await getter(workspace, path))
return {
deployed: draftOnly ? {} : canonicalizeDraftDiffValue(kind, deployed, false),
draft: canonicalizeDraftDiffValue(kind, draft, true),
hasDraft,
noDeployed
}
}
}
/**
* Whether a draft's base is stale: the deployed version the draft forked from
* no longer matches the current deployed head — a newer version was deployed
* after the draft began, so deploying the draft would silently revert it.
* Scripts compare the draft's `parent_hash` vs the deployed `hash`; flows the
* pinned `version_id` vs the deployed head `version_id`; apps (incl. raw) the
* pinned `parent_version` vs the head of `versions`. `r` is the item fetched
* with `get_draft=true`; only script/flow/app kinds carry a base pointer.
*/
export function draftBaseIsStale(draftKind: UserDraftItemKind, r: any): boolean {
const draft = r?.draft
if (!draft) return false
if (draftKind === 'script') {
return !!r.hash && !!draft.parent_hash && draft.parent_hash !== r.hash
}
if (draftKind === 'flow') {
return r.version_id != null && draft.version_id != null && draft.version_id !== r.version_id
}
const head = Array.isArray(r.versions) ? r.versions[r.versions.length - 1] : undefined
return head != null && draft.parent_version != null && draft.parent_version !== head
}
/** Fetch-and-test wrapper over `draftBaseIsStale` for one draft item. Returns
* false for kinds without a base pointer and on fetch errors (warn, not block). */
export async function fetchDraftBaseStale(
draftKind: UserDraftItemKind,
path: string,
workspace: string
): Promise<boolean> {
try {
if (draftKind === 'script') {
const r = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
return draftBaseIsStale(draftKind, r)
}
if (draftKind === 'flow') {
const r = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
return draftBaseIsStale(draftKind, r)
}
if (draftKind === 'app' || draftKind === 'raw_app') {
// The apps endpoint auto-detects a raw app and overlays its draft.
const r = await AppService.getAppByPath({ workspace, path, getDraft: true })
return draftBaseIsStale(draftKind, r)
}
return false
} catch (e) {
console.error(`Stale-draft check failed for ${draftKind}:${path}`, e)
return false
}
}
/**
* Deploy a script/flow draft's trigger changes the same way the editors do.
* Scripts and flows can carry `draft_triggers`; the create/update call below
* deletes the draft row, so without this the saved trigger edits would be
* silently lost. Uses the shared `deployTriggers` (a throwaway `usedTriggerKinds`
* store is fine — it only tracks kinds for the editor UI). `isNew` forces each
* trigger's `script_path` to the deployed path (matches the editors' new path).
*/
async function deployDraftTriggers(
draftTriggers: Trigger[] | undefined,
workspace: string,
path: string,
isNew: boolean
): Promise<void> {
const triggers = (draftTriggers ?? []).filter((t) => t?.draftConfig)
if (triggers.length === 0) return
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
await deployTriggers(triggers, workspace, isAdmin, writable<string[]>([]), path, isNew)
}
/**
* Promote a draft to deployed by replaying the editor's create/update call with
* the stored draft value. The matching draft row is deleted server-side by the
* create/update handler. Returns the same `{ success, error? }` shape as the
* fork-merge `deployItem`, so callers can reuse the `deploymentStatus` pattern.
*/
export async function deployDraft(
kind: DraftKind,
path: string,
workspace: string,
opts: { draftOnly?: boolean; rawApp?: boolean; deploymentMessage?: string } = {}
): Promise<DeployResult> {
const { draftOnly = false, rawApp = false, deploymentMessage } = opts
try {
if (kind === 'raw_app' || (kind === 'app' && rawApp)) {
// Raw apps bundle their source files and deploy via the raw-app
// endpoints. Reached as `kind === 'raw_app'` (Review & Deploy) or
// `kind === 'app'` + `rawApp` (editor). Must route here: the
// visual-app branch would `updateApp` with no `value` (RawAppDraft
// has none) and silently drop the draft's files.
await deployRawAppDraft(workspace, path, deploymentMessage)
} else if (kind === 'script') {
const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any
const d = r.draft ?? r
// Drop editor-only / server-managed keys; deploy as a real (non-draft) version.
const { draft_triggers: draftTriggers, draft_only: _o, ...rest } = d
const scriptPath = d.path ?? path
// Deploy at the draft's path so a rename in the draft is honored (same as
// the editor: createScript at the new path with parent_hash links lineage).
await ScriptService.createScript({
workspace,
requestBody: {
...rest,
path: scriptPath,
parent_hash: r.hash,
deployment_message: deploymentMessage
}
})
// Then deploy any draft trigger edits, so they aren't dropped with the draft.
await deployDraftTriggers(draftTriggers, workspace, scriptPath, true)
} else if (kind === 'flow') {
const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any
const d = r.draft ?? r
const requestBody = {
// Deploy at the draft's intended path: flow/app/raw-app drafts keep the
// user-typed path in `draft_path` (a never-deployed item is parked at a
// synthetic `u/{user}/draft_{uuid}` storage key). The URL `path` stays
// that storage key.
path: d.draft_path ?? d.path ?? path,
summary: d.summary ?? '',
description: d.description ?? '',
value: d.value,
schema: d.schema,
tag: d.tag,
dedicated_worker: d.dedicated_worker,
ws_error_handler_muted: d.ws_error_handler_muted,
visible_to_runner_only: d.visible_to_runner_only,
on_behalf_of_email: d.on_behalf_of_email,
labels: d.labels,
deployment_message: deploymentMessage
}
// Draft-only flows have NO flow row (they live solely in the
// draft table), so they deploy via createFlow; a draft on a
// deployed flow updates it.
if (draftOnly) {
await FlowService.createFlow({ workspace, requestBody })
} else {
await FlowService.updateFlow({ workspace, path, requestBody })
}
// Then deploy any draft trigger edits, so they aren't dropped with the draft.
await deployDraftTriggers(
d.draft_triggers,
workspace,
d.draft_path ?? d.path ?? path,
draftOnly
)
} else if (kind === 'app') {
// `raw_app` is handled above; only visual apps reach here.
const r = (await AppService.getAppByPath({ workspace, path, getDraft: true })) as any
// A visual-app draft is stored as the *bare* app value (grid/theme/...,
// plus a `draft_path` when the path was renamed) — NOT wrapped in
// { value, summary, policy } like script/flow drafts. So the deploy value
// is the draft object itself; fall back to the deployed value when there's
// no draft. `draft_path` and `summary` are draft-only fields mirrored onto
// the App value (the editor drops them on deploy), so strip them from the
// value and apply them as the deploy path / summary column.
const draft = r.draft as Record<string, any> | undefined
const { draft_path: draftPath, summary: draftSummary, ...appValue } = draft ?? r.value ?? {}
// Policy isn't carried in the app draft, so it comes from the deployed app
// (or a default). custom_path requires admin on update; non-admins send
// undefined so the backend preserves the existing route. The draft has no
// custom_path, so admins fall back to the deployed route (`''` when none).
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
const policy = r.policy ?? { execution_mode: 'publisher' }
const requestBody = {
value: appValue,
summary: draftSummary ?? r.summary ?? '',
policy,
// Honor the draft's intended path; `draft_path` holds the user-typed path
// for a never-deployed app parked at a `u/{user}/draft_{uuid}` storage key.
path: draftPath ?? r.path ?? path,
custom_path: isAdmin ? (r.custom_path ?? '') : undefined,
deployment_message: deploymentMessage,
// The draft carries no on-behalf-of selector — the policy comes straight
// from the deployed app. Preserve its on_behalf_of (the backend resets it
// to the deploying user without this flag, gated by can_preserve_on_behalf_of).
preserve_on_behalf_of: policy?.on_behalf_of ? true : undefined
}
// Same as flows: draft-only apps have no app row → create;
// drafts on a deployed app update it.
if (draftOnly) {
await AppService.createApp({ workspace, requestBody })
} else {
await AppService.updateApp({ workspace, path, requestBody })
}
} 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 }
if (draftOnly) {
await VariableService.createVariable({
workspace,
requestBody: {
path: d.path ?? path,
value: d.variable?.value ?? '',
is_secret: !!d.variable?.is_secret,
description: d.variable?.description ?? '',
labels: d.labels,
ws_specific: d.wsSpecific
}
})
} else {
await VariableService.updateVariable({
workspace,
path,
requestBody: {
path: d.path !== path ? d.path : undefined,
// '' = untouched secret value; sending it would blank the secret.
value: d.variable?.value === '' ? undefined : d.variable?.value,
is_secret: d.variable?.is_secret,
description: d.variable?.description,
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:
// { path, description, args, resource_type?, labels?, wsSpecific }
if (draftOnly) {
await ResourceService.createResource({
workspace,
requestBody: {
path: d.path ?? path,
value: d.args ?? {},
description: d.description ?? '',
resource_type: d.resource_type ?? deployed.resource_type,
labels: d.labels,
ws_specific: d.wsSpecific
}
})
} else {
await ResourceService.updateResource({
workspace,
path,
requestBody: {
path: d.path ?? path,
value: d.args ?? {},
description: d.description ?? '',
labels: d.labels,
ws_specific: d.wsSpecific
}
})
}
} else if (kind === 'trigger_schedule') {
const { draft: d } = splitOverlay(await OVERLAY_GETTERS.trigger_schedule!(workspace, path))
// The schedule editor's draft IS the cfg shape `saveScheduleFromCfg`
// consumes — same save the editor's Deploy button runs.
const ok = await saveScheduleFromCfg({ ...d, path: d.path ?? path }, !draftOnly, workspace)
if (!ok) {
return { success: false, error: 'Schedule save failed' }
}
} else if (kind in TRIGGER_SAVERS) {
const getter = OVERLAY_GETTERS[kind]!
const { draft: d } = splitOverlay(await getter(workspace, path))
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
const ok = await TRIGGER_SAVERS[kind]!(
path,
{ ...d, path: d.path ?? path },
!draftOnly,
workspace,
isAdmin
)
if (!ok) {
return { success: false, error: 'Trigger save failed' }
}
} else {
return { success: false, error: `Deploy not supported for draft kind ${kind}` }
}
// Delete the draft at its STORAGE path (the row key, = the `path` arg).
// Two reasons it must happen here for every kind, mirroring the editors'
// post-deploy `discardDraftAfterDeploy(draftPath)`:
// - Drawer kinds (variable / resource / triggers) aren't deleted by
// their create/update endpoints at all.
// - script/flow/app/raw_app DO delete server-side, but only the draft at
// the *deployed* path (`d.path`). A renamed draft_only item lives at a
// synthetic `u/{user}/draft_{uuid}` storage path ≠ `d.path`, so its
// draft row survives the deploy and keeps listing. Deleting the
// storage-path draft removes it (a no-op when the server already did).
await UserDraftDbSyncer.save({
workspace,
itemKind: kind,
path,
value: null,
immediate: true
})
// Mutated the workspace's Server Drafts — refresh every mounted reader.
invalidateWorkspaceDrafts(workspace)
// The DEPLOYED state moved: cached fork comparisons involving this
// workspace (as fork or as parent) are no longer trustworthy. Draft-only
// mutations skip this — they never move the deployed tally.
invalidateWorkspaceComparison(workspace)
// For script/flow/app the server-side delete bypasses UserDraftDbSyncer,
// so the syncer-owned hint won't auto-clear — clear it explicitly.
// (Idempotent: the drawer-kind delete above already cleared it.)
setLocalDraftHint(workspace, kind, path, false)
return { success: true }
} catch (e: any) {
return { success: false, error: e?.body ?? e?.message ?? String(e) }
}
}
/** Kind → editor save helper for the standalone trigger kinds, all sharing the
* `(initialPath, cfg, edit, workspace, isAdmin?)` shape. The throwaway
* `usedTriggerKinds` store only feeds the editors' kind-usage UI. */
const TRIGGER_SAVERS: Partial<
Record<
DraftKind,
(
initialPath: string,
cfg: Record<string, any>,
edit: boolean,
workspace: string,
isAdmin: boolean
) => Promise<boolean>
>
> = {
trigger_http: (p, cfg, edit, ws, isAdmin) =>
saveHttpRouteFromCfg(p, cfg, edit, ws, isAdmin, writable<string[]>([])),
trigger_websocket: (p, cfg, edit, ws) =>
saveWebsocketTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
trigger_postgres: (p, cfg, edit, ws) =>
savePostgresTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
trigger_kafka: (p, cfg, edit, ws) =>
saveKafkaTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
trigger_nats: (p, cfg, edit, ws) =>
saveNatsTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
trigger_mqtt: (p, cfg, edit, ws) =>
saveMqttTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
trigger_amqp: (p, cfg, edit, ws) =>
saveAmqpTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
trigger_sqs: (p, cfg, edit, ws) =>
saveSqsTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
trigger_gcp: (p, cfg, edit, ws) =>
saveGcpTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
trigger_azure: (p, cfg, edit, ws) =>
saveAzureTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
trigger_email: (p, cfg, edit, ws, isAdmin) =>
saveEmailTriggerFromCfg(p, cfg, edit, ws, isAdmin, writable<string[]>([]))
}
/**
* Discard a draft. Draft-only items exist only as a draft-table row, so
* deleting that row is the whole discard in every case. `save({ value: null })`
* is the canonical "drop my draft for this path" POST.
*
* A legacy draft (workspace-level, `email IS NULL`) isn't owned by the authed
* user, so the email-scoped syncer delete can't reach it. Discard it via a
* direct `legacy` delete instead — then clear the local hint + invalidate as
* the syncer path would.
*/
export async function discardDraft(
kind: DraftKind,
path: string,
workspace: string,
_draftOnly = false,
legacy = false
): Promise<DeployResult> {
try {
if (legacy) {
await DraftService.updateDraft({
workspace,
kind,
path,
requestBody: { value: null, legacy: true }
})
setLocalDraftHint(workspace, kind, path, false)
invalidateWorkspaceDrafts(workspace)
return { success: true }
}
// postSave clears the syncer-owned `*` hint on the delete. `immediate`
// so the await resolves after the POST lands — else it resolves at
// enqueue time and the invalidate below refetches before the delete,
// re-listing the just-discarded draft.
await UserDraftDbSyncer.save({ workspace, itemKind: kind, path, value: null, immediate: true })
invalidateWorkspaceDrafts(workspace)
return { success: true }
} catch (e: any) {
return { success: false, error: e?.body ?? e?.message ?? String(e) }
}
}