feat: detect and guard against deploying stale drafts (#9768)

* feat: detect and guard against deploying stale drafts

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

* feat: extend stale-draft warning to low-code app drafts

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

* refactor: discard stale draft on rebase instead of resetting to latest

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

* feat: animate AI chat thinking block open/close like tool calls

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

* feat: detect stale flow/app drafts by pinned version at load and deploy

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

* fix: reset version-staleness state on new drafts and after app deploy

Addresses review: new-draft route reuse left stale version/draftBaseVersion (false stale-draft modal on a fresh flow/app); app deploy left parent_version pinned to the superseded base (false 'not latest' on a follow-up deploy).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-06-25 14:12:12 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 11d83ab1ec
commit d865518934
16 changed files with 773 additions and 25 deletions
@@ -192,7 +192,7 @@
// side to diff the name against. Raw apps are fetched via the apps endpoint too
// (it auto-detects raw from the deployed row and overlays the raw_app draft).
const summaryCache = $state<
Record<string, { deployed?: string; draft?: string; loading?: boolean }>
Record<string, { deployed?: string; draft?: string; stale?: boolean; loading?: boolean }>
>({})
async function fetchDraftSummary(item: Row) {
@@ -216,9 +216,27 @@
path: item.path,
getDraft: true
}))) as any
// A draft is stale when the version it forked from no longer matches the
// current deployed head: a newer version was deployed after the draft began.
// Scripts compare `parent_hash` vs the deployed `hash`; flows the pinned
// `version_id` vs the deployed head `version_id`; apps the pinned
// `parent_version` vs the deployed head (`versions[last]`).
const draftBlob = r.draft as any
const appHead = Array.isArray(r.versions) ? r.versions[r.versions.length - 1] : undefined
const stale =
item.draftKind === 'script'
? !!r.hash && !!draftBlob?.parent_hash && draftBlob.parent_hash !== r.hash
: item.draftKind === 'flow'
? r.version_id != null &&
draftBlob?.version_id != null &&
draftBlob.version_id !== r.version_id
: appHead != null &&
draftBlob?.parent_version != null &&
draftBlob.parent_version !== appHead
summaryCache[item.key] = {
deployed: r.summary,
draft: (r.draft as any)?.summary,
draft: draftBlob?.summary,
stale,
loading: false
}
} catch (error) {
@@ -618,6 +636,19 @@
{/snippet}
</Popover>
{/if}
{#if draftItem.mine && summaryCache[draftItem.key]?.stale}
<Popover openOnHover debounceDelay={50}>
{#snippet trigger()}
<AlertTriangle size={16} class="text-orange-500" />
{/snippet}
{#snippet content()}
<div class="text-xs p-3 max-w-xs text-primary">
Started from an older deployed version. A newer version was deployed after this
draft began. Review the latest deploy before deploying.
</div>
{/snippet}
</Popover>
{/if}
{#if deploymentStatus[draftItem.key]?.status !== 'deployed'}
{#if draftItem.draftKind === 'data_pipeline'}
<!-- A bundle isn't diffable/deployable here — its scripts deploy
@@ -113,6 +113,7 @@
disabledFlowInputs = false,
savedPrimarySchedule = undefined,
version = undefined,
draftBaseVersion = undefined,
draftTriggersFromUrl = undefined,
selectedTriggerIndexFromUrl = undefined,
children,
@@ -220,7 +221,12 @@
}
let onLatest = true
async function compareVersions() {
if (version === undefined) {
// Compare the draft's pinned fork base against the current head when editing
// a draft, else the load-time head. This catches both a concurrent deploy
// (head moved since open) AND a stale draft reopened after a deploy (head ==
// load-time head, but the draft was forked from an older version).
const base = draftBaseVersion ?? version
if (base === undefined) {
return
}
try {
@@ -230,7 +236,7 @@
path: initialPath
})
onLatest = version === flowVersion?.id
onLatest = base === flowVersion?.id
} else {
onLatest = true
}
@@ -377,6 +377,11 @@
path: npath
})
version = appHistory[0]?.version
// Re-pin the fork base to the just-deployed head: the editor stays open, so a
// follow-up deploy (or a new edit) would otherwise compare against the now-
// superseded base and falsely warn. parent_version is in
// DRAFT_COMPARE_IGNORED_FIELDS, so this write can't spawn a spurious draft.
if ($app) $app.parent_version = version
closeSaveDrawer()
sendUserToast('App deployed successfully')
@@ -421,7 +426,12 @@
let onLatest = $state(true)
async function compareVersions() {
if (version === undefined) {
// Compare the draft's pinned fork base (`$app.parent_version`) against the
// current head when editing a draft, else the load-time head. Catches both a
// concurrent deploy (head moved since open) AND a stale draft reopened after a
// deploy (head == load-time head, but the draft was forked from an older one).
const base = $app?.parent_version ?? version
if (base === undefined) {
return
}
try {
@@ -429,7 +439,7 @@
workspace: $workspaceStore!,
path: $appPath
})
onLatest = appVersion?.version === undefined || version === appVersion?.version
onLatest = appVersion?.version === undefined || base === appVersion?.version
} catch (e) {
console.error('Error comparing versions', e)
onLatest = true
@@ -198,6 +198,13 @@ export type App = {
hideLegacyTopBar?: boolean | undefined
mobileViewOnSmallerScreens?: boolean | undefined
version?: number
/**
* Fork base for the stale-draft check: the deployed app version this draft
* was started from, pinned at fork. Stamped on the draft seed in the editor;
* compared against the deployed head (`versions[last]`) in the compare view.
* In DRAFT_COMPARE_IGNORED_FIELDS so it never trips the autosave no-op check.
*/
parent_version?: number
/**
* User-typed path persisted on the autosaved App when it differs from
* the deployed/seeded baseline. The home list renders it so a friendly
@@ -38,6 +38,13 @@
draftSavedAt?: string | undefined
/** ISO timestamp of the latest deploy at this path. */
deployedAt?: string | undefined
/** Precise staleness inputs (flows/apps): the deployed version the draft was
* forked from, and the current deployed head. When both are set they drive
* `isStale` and the dedup key instead of the timestamps — exact, and stable
* across autosaves (the timestamp drifts past `deployedAt` as you keep
* editing). Absent (pre-feature drafts, scripts) ⇒ timestamp fallback. */
draftBaseVersion?: number | undefined
deployedHeadVersion?: number | undefined
/** Discard the draft and reload deployed (same as "Reset to deployed"). */
onLoadLatestDeploy?: () => void | Promise<void>
/** Defaults to true; set to false to suppress all modals. */
@@ -56,6 +63,8 @@
othersModalOpen = $bindable(),
draftSavedAt = undefined,
deployedAt = undefined,
draftBaseVersion = undefined,
deployedHeadVersion = undefined,
onLoadLatestDeploy,
enabled = true
}: Props = $props()
@@ -65,13 +74,27 @@
let staleAlertKey = $state<string | undefined>(undefined)
let staleModalOpen = $state(false)
// Prefer the exact version comparison (flows/apps) over the timestamp: the
// draft's pinned fork base never drifts, whereas `draftSavedAt` advances past
// `deployedAt` once you keep editing a stale draft, hiding the staleness.
const useVersion = $derived(draftBaseVersion != null && deployedHeadVersion != null)
const isStale = $derived(
!!draftSavedAt &&
!!deployedAt &&
!!onLoadLatestDeploy &&
new Date(draftSavedAt).getTime() < new Date(deployedAt).getTime()
!!onLoadLatestDeploy &&
(useVersion
? draftBaseVersion !== deployedHeadVersion
: !!draftSavedAt &&
!!deployedAt &&
new Date(draftSavedAt).getTime() < new Date(deployedAt).getTime())
)
// Key on the versions (not `draftSavedAt`) in the version path, else every
// autosave would mint a new key and re-pop the modal mid-edit.
const currentKey = $derived(
isStale
? useVersion
? `${path}|v|${draftBaseVersion}|${deployedHeadVersion}`
: `${path}|${draftSavedAt}|${deployedAt}`
: undefined
)
const currentKey = $derived(isStale ? `${path}|${draftSavedAt}|${deployedAt}` : undefined)
$effect(() => {
const key = currentKey
@@ -3,6 +3,7 @@
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { twMerge } from 'tailwind-merge'
import { Brain, ChevronDown, ChevronRight, Loader2 } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import type { DisplayMessage } from './shared'
import CodeDisplay from './script/CodeDisplay.svelte'
import LinkRenderer from './LinkRenderer.svelte'
@@ -95,6 +96,7 @@
{#if reasoningExpanded}
<div
transition:slide={{ duration: 150 }}
class="p-2 bg-surface text-secondary break-words prose prose-sm dark:prose-invert max-w-full leading-snug
prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-ul:!pl-5
prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1
@@ -64,6 +64,9 @@ vi.mock('$lib/gen', async () => {
getScriptByPath: vi.fn(async () => {
throw new Error('getScriptByPath mock not configured')
}),
getScriptByHash: vi.fn(async () => {
throw new Error('getScriptByHash mock not configured')
}),
queryHubScripts: vi.fn(async () => []),
getHubScriptContentByPath: vi.fn(async () => ''),
listScripts: vi.fn(async () => [])
@@ -119,6 +122,9 @@ vi.mock('$lib/gen', async () => {
getFlowByPath: vi.fn(async () => {
throw new Error('getFlowByPath mock not configured')
}),
getFlowVersion: vi.fn(async () => {
throw new Error('getFlowVersion mock not configured')
}),
getFlowLatestVersion: vi.fn(async () => ({ id: 1 })),
listFlows: vi.fn(async () => [])
}),
@@ -141,6 +147,9 @@ vi.mock('$lib/gen', async () => {
getAppByPath: vi.fn(async () => {
throw new Error('getAppByPath mock not configured')
}),
getAppByVersion: vi.fn(async () => {
throw new Error('getAppByVersion mock not configured')
}),
listApps: vi.fn(async () => [])
}),
ResourceService: wrapService(actual.ResourceService, {
@@ -209,6 +218,13 @@ vi.mock('./rawAppBundlerBridge', () => ({
}))
}))
vi.mock('$lib/infer', async () => ({
...(await vi.importActual<any>('$lib/infer')),
// Avoid the wasm parser in unit tests: the script deploy path infers the arg
// schema but tolerates failure, and these tests don't assert on the schema.
inferArgs: vi.fn(async () => {})
}))
import {
globalTools,
globalToolsFor,
@@ -1181,6 +1197,260 @@ describe('global AI tools', () => {
})
})
describe('stale-draft deploy guard and rebase', () => {
// The suite's beforeEach only clears mock calls (not implementations), so
// restore the script-service mocks these tests override back to their factory
// defaults; otherwise a persistent resolved value leaks into later tests.
afterEach(() => {
vi.mocked(ScriptService.existsScriptByPath).mockResolvedValue(false)
vi.mocked(ScriptService.getScriptByPath).mockImplementation(async () => {
throw new Error('getScriptByPath mock not configured')
})
vi.mocked(ScriptService.getScriptByHash).mockImplementation(async () => {
throw new Error('getScriptByHash mock not configured')
})
vi.mocked(FlowService.existsFlowByPath).mockResolvedValue(false)
vi.mocked(FlowService.getFlowByPath).mockImplementation(async () => {
throw new Error('getFlowByPath mock not configured')
})
vi.mocked(FlowService.getFlowVersion).mockImplementation(async () => {
throw new Error('getFlowVersion mock not configured')
})
vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValue({ id: 1 } as any)
vi.mocked(AppService.existsApp).mockResolvedValue(false)
vi.mocked(AppService.getAppByPath).mockImplementation(async () => {
throw new Error('getAppByPath mock not configured')
})
vi.mocked(AppService.getAppByVersion).mockImplementation(async () => {
throw new Error('getAppByVersion mock not configured')
})
})
function seedStaleScriptDraft(path: string, parentHash: string, content = 'draft content') {
seedBackendDraft('script', path, {
path,
summary: 's',
description: '',
content,
language: 'bun',
kind: 'script',
parent_hash: parentHash,
schema: {}
})
}
function mockDeployedScript(path: string, hash: string, content = 'latest deployed') {
vi.mocked(ScriptService.existsScriptByPath).mockResolvedValue(true)
vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({
path,
hash,
content,
language: 'bun',
summary: 's'
} as any)
}
it('blocks deploying a script draft started from an older deployed version', async () => {
seedStaleScriptDraft('f/scripts/stale', 'base-hash')
mockDeployedScript('f/scripts/stale', 'new-hash')
await expect(
callGlobalTool('deploy_workspace_item', { type: 'script', path: 'f/scripts/stale' })
).rejects.toThrow(/older deployed version/)
expect(ScriptService.createScript).not.toHaveBeenCalled()
})
it('deploys a stale script draft when force is set', async () => {
seedStaleScriptDraft('f/scripts/stale', 'base-hash')
mockDeployedScript('f/scripts/stale', 'new-hash')
const result = JSON.parse(
await callGlobalTool('deploy_workspace_item', {
type: 'script',
path: 'f/scripts/stale',
force: true
})
)
expect(result.success).toBe(true)
expect(ScriptService.createScript).toHaveBeenCalled()
})
it('deploys a script draft that is based on the current deployed head', async () => {
seedStaleScriptDraft('f/scripts/fresh', 'head-hash')
mockDeployedScript('f/scripts/fresh', 'head-hash')
const result = JSON.parse(
await callGlobalTool('deploy_workspace_item', { type: 'script', path: 'f/scripts/fresh' })
)
expect(result.success).toBe(true)
expect(ScriptService.createScript).toHaveBeenCalled()
})
it('rebase_draft discards the stale draft and surfaces the changes to replay', async () => {
seedStaleScriptDraft('f/scripts/stale', 'base-hash', 'base content\nmy added line\n')
mockDeployedScript('f/scripts/stale', 'new-hash', 'latest deployed content\n')
vi.mocked(ScriptService.getScriptByHash).mockResolvedValue({
hash: 'base-hash',
content: 'base content\n',
language: 'bun'
} as any)
const result = JSON.parse(
await callGlobalTool('rebase_draft', { type: 'script', path: 'f/scripts/stale' })
)
expect(result.success).toBe(true)
expect(result.latest_hash).toBe('new-hash')
// The diff surfaces the draft's own change over its fork base.
expect(result.your_changes).toContain('my added line')
// The stale draft is discarded (not reset), so a premature deploy fails
// cleanly rather than silently shipping the latest unchanged.
expect(getBackendDraft('script', 'f/scripts/stale', { workspace: WORKSPACE })).toBeUndefined()
await expect(
callGlobalTool('deploy_workspace_item', { type: 'script', path: 'f/scripts/stale' })
).rejects.toThrow(/No .*draft/)
expect(ScriptService.createScript).not.toHaveBeenCalled()
// Re-applying re-bases onto the current head; the deploy then passes.
await callGlobalTool('write_script', {
path: 'f/scripts/stale',
summary: 's',
language: 'bun',
content: 'latest deployed content\nmy added line\n'
})
const deploy = JSON.parse(
await callGlobalTool('deploy_workspace_item', { type: 'script', path: 'f/scripts/stale' })
)
expect(deploy.success).toBe(true)
expect(ScriptService.createScript).toHaveBeenCalled()
})
function seedStaleFlowDraft(path: string, versionId: number, modules: any[] = []) {
seedBackendDraft('flow', path, {
path,
summary: 'f',
description: '',
version_id: versionId,
value: { modules },
schema: {}
})
}
function mockDeployedFlow(path: string, versionId: number) {
vi.mocked(FlowService.existsFlowByPath).mockResolvedValue(true)
vi.mocked(FlowService.getFlowByPath).mockResolvedValue({
path,
summary: 'f',
version_id: versionId,
value: { modules: [] },
schema: {}
} as any)
}
it('blocks deploying a flow draft started from an older deployed version', async () => {
seedStaleFlowDraft('f/flows/stale', 1)
mockDeployedFlow('f/flows/stale', 2)
await expect(
callGlobalTool('deploy_workspace_item', { type: 'flow', path: 'f/flows/stale' })
).rejects.toThrow(/older deployed version/)
expect(FlowService.updateFlow).not.toHaveBeenCalled()
expect(FlowService.createFlow).not.toHaveBeenCalled()
})
it('rebase_draft discards the stale flow draft and surfaces the changes to replay', async () => {
seedStaleFlowDraft('f/flows/stale', 1, [{ id: 'a', value: { type: 'identity' } }])
mockDeployedFlow('f/flows/stale', 2)
vi.mocked(FlowService.getFlowVersion).mockResolvedValue({
value: { modules: [] }
} as any)
const result = JSON.parse(
await callGlobalTool('rebase_draft', { type: 'flow', path: 'f/flows/stale' })
)
expect(result.success).toBe(true)
expect(result.latest_version).toBe(2)
expect(result.your_changes).toContain('identity')
// The stale draft is discarded, so a premature deploy fails cleanly.
expect(getBackendDraft('flow', 'f/flows/stale', { workspace: WORKSPACE })).toBeUndefined()
await expect(
callGlobalTool('deploy_workspace_item', { type: 'flow', path: 'f/flows/stale' })
).rejects.toThrow(/No .*draft/)
expect(FlowService.updateFlow).not.toHaveBeenCalled()
})
function seedStaleAppDraft(path: string, parentVersion: number, file = 'old') {
seedBackendDraft('raw_app', path, {
summary: 'a',
files: { '/index.tsx': file },
runnables: {},
data: { tables: [] },
parent_version: parentVersion
})
}
function mockDeployedApp(path: string, versionId: number, file = 'latest') {
vi.mocked(AppService.existsApp).mockResolvedValue(true)
vi.mocked(AppService.getAppByPath).mockResolvedValue({
path,
summary: 'a',
versions: [versionId],
value: { files: { '/index.tsx': file }, runnables: {}, data: { tables: [] } },
policy: { execution_mode: 'publisher' }
} as any)
}
it('grafts the fork-base version onto a new app draft and keeps it through the save whitelist', async () => {
// No draft yet: the first app edit projects the deployed app into a draft.
// This exercises the runtime path types can't catch — the graft in
// appSourceToDraftValue AND survival through normalizeAppDraftValue's whitelist.
mockDeployedApp('f/apps/fresh', 5)
await callGlobalTool('write_app_file', {
path: 'f/apps/fresh',
file_path: '/src/New.tsx',
content: 'export default function New() { return null }'
})
const draft = getBackendDraft<any>('raw_app', 'f/apps/fresh', { workspace: WORKSPACE })
expect(draft.parent_version).toBe(5)
})
it('blocks deploying an app draft started from an older deployed version', async () => {
seedStaleAppDraft('f/apps/stale', 1)
mockDeployedApp('f/apps/stale', 2)
await expect(
callGlobalTool('deploy_workspace_item', { type: 'app', path: 'f/apps/stale' })
).rejects.toThrow(/older deployed version/)
expect(AppService.createAppRaw).not.toHaveBeenCalled()
expect(AppService.updateAppRaw).not.toHaveBeenCalled()
})
it('rebase_draft discards the stale app draft and surfaces the changes to replay', async () => {
seedStaleAppDraft('f/apps/stale', 1, 'my-change')
mockDeployedApp('f/apps/stale', 2, 'latest-deployed')
vi.mocked(AppService.getAppByVersion).mockResolvedValue({
value: { files: { '/index.tsx': 'base' }, runnables: {}, data: { tables: [] } }
} as any)
const result = JSON.parse(
await callGlobalTool('rebase_draft', { type: 'app', path: 'f/apps/stale' })
)
expect(result.success).toBe(true)
expect(result.latest_version).toBe(2)
expect(result.your_changes).toContain('my-change')
// The stale draft is discarded, so a premature deploy fails cleanly.
expect(getBackendDraft('raw_app', 'f/apps/stale', { workspace: WORKSPACE })).toBeUndefined()
await expect(
callGlobalTool('deploy_workspace_item', { type: 'app', path: 'f/apps/stale' })
).rejects.toThrow(/No .*draft/)
expect(AppService.updateAppRaw).not.toHaveBeenCalled()
})
})
it('preserves existing flow metadata and seeds freshness on first flow write', async () => {
vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true)
vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any)
@@ -17,6 +17,7 @@ import {
WebsocketTriggerService,
WorkspaceService
} from '$lib/gen'
import { createTwoFilesPatch } from 'diff'
import { $ScriptLang } from '$lib/gen/schemas.gen'
import type {
AppWithLastVersion,
@@ -417,7 +418,20 @@ const deployWorkspaceItemSchema = z.object({
deployment_message: z
.string()
.optional()
.describe('Optional deployment message recorded with the change.')
.describe('Optional deployment message recorded with the change.'),
force: z
.boolean()
.optional()
.describe(
'Deploy even if the draft was started from an older deployed version, overwriting the version deployed since. Defaults to false; prefer calling rebase_draft first to keep the newer changes.'
)
})
const rebaseDraftSchema = z.object({
type: itemTypeSchema,
path: z
.string()
.describe('Workspace path of the draft to rebase onto the latest deployed version.')
})
const editScriptSchema = z.object({
@@ -764,13 +778,13 @@ Rules:
- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run.
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit.
- Keep context targeted.${
previewTools
? `
previewTools
? `
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.
- When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app").
- get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend.<id> call), call list_app_runs to get that run's job_id from the live preview, then get_job_logs with it. Use this when a backend call errors or returns something unexpected.`
: ''
}
: ''
}
Documentation:
- Use search_docs to look up how a Windmill feature works in the official documentation (a flag, concept, function, or "does Windmill support X") instead of guessing about product behavior. It returns matching doc snippets with their Source URL; call read_docs_page with a Source URL to read the full page (or a section, if it returns headings). Cite the Source URL when you rely on it.
@@ -797,15 +811,15 @@ Data Tables:
- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.
- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.${
skills.length > 0
? `
skills.length > 0
? `
Skills:
- Skills are reusable instruction sets curated for this workspace, each covering a specific kind of task. The available skills are listed below by name and description.
- When a user's request matches a skill's description, call read_skill with its exact name to load the full instructions BEFORE acting, then follow them.
${skills.map((s) => `- ${s.name}: ${s.description}`).join('\n')}`
: ''
}`
: ''
}`
}
const DEFAULT_LIST_TYPES = ['script', 'flow'] as const satisfies readonly WorkspaceItemType[]
@@ -1950,6 +1964,20 @@ export const globalTools: Tool<{}>[] = [
return deployDraft(parsed, { ...ctx, sessionId: sessionIdFromCtx(ctx) })
}
},
{
def: createToolDef(
rebaseDraftSchema,
'rebase_draft',
'Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when deploy_workspace_item reports the draft was started from an older deployed version.',
{ strict: false }
),
showDetails: true,
showFade: true,
fn: async (ctx) => {
const parsed = rebaseDraftSchema.parse(ctx.args)
return rebaseDraft(parsed, ctx)
}
},
{
def: createToolDef(
deleteWorkspaceItemSchema,
@@ -3702,6 +3730,269 @@ async function discardLocalDraft(
)
}
// A draft started from an older deploy would silently overwrite whatever was
// deployed since. Block the deploy and point the model at rebase_draft, unless it
// explicitly forces the overwrite. `base`/`head` undefined ⇒ can't tell ⇒ allow.
function assertDraftBasedOnLatest(
type: WorkspaceItemType,
path: string,
base: string | number | undefined,
head: string | number | undefined,
force: boolean | undefined
): void {
if (force || base == null || head == null || base === head) return
throw new Error(
`This ${type} draft "${path}" was started from an older deployed version (forked from ${base}, ` +
`latest is ${head}). Deploying now would overwrite the version deployed since. Call rebase_draft to ` +
`discard the stale draft and get your changes back as a diff, then re-apply them (the new draft ` +
`re-bases onto the latest version) and deploy. To deploy as-is and replace the newer version, call ` +
`deploy_workspace_item again with force: true.`
)
}
// Discard a stale draft and return its own changes (vs the fork base) as a diff so
// the model can re-apply them on the latest deploy. Discarding rather than resetting
// keeps the base pointer honest (the next write re-bases on the current head) and
// fails safe: a premature deploy hits "no draft" instead of silently shipping the
// latest unchanged.
async function rebaseDraft(
args: { type: WorkspaceItemType; path: string },
ctx: WriteDraftCtx
): Promise<string> {
switch (args.type) {
case 'script':
return rebaseScriptDraft(args.path, ctx)
case 'flow':
return rebaseFlowDraft(args.path, ctx)
case 'app':
return rebaseAppDraft(args.path, ctx)
default:
throw new Error('rebase_draft currently supports scripts, flows, and apps.')
}
}
async function rebaseScriptDraft(path: string, ctx: WriteDraftCtx): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const draft = await getGlobalDraft(workspace, 'script', path)
if (!draft || typeof draft.value !== 'string' || !draft.language) {
throw new Error(`No script draft found for "${path}".`)
}
if (!(await ScriptService.existsScriptByPath({ workspace, path }))) {
throw new Error(`Script "${path}" is not deployed; there is no newer version to rebase onto.`)
}
const latest = await ScriptService.getScriptByPath({ workspace, path })
const baseHash = draft.parentHash
if (baseHash && baseHash === latest.hash) {
const message = `Draft "${path}" is already based on the latest deployed version (${latest.hash}).`
toolCallbacks.setToolStatus(toolId, { content: message })
return JSON.stringify({ success: true, alreadyLatest: true, latest_hash: latest.hash, message })
}
toolCallbacks.setToolStatus(toolId, { content: `Rebasing draft "${path}" onto latest...` })
// Capture the draft's own changes (vs its fork base) BEFORE discarding — this
// diff is the only clean record of what to replay. Best-effort: if the base
// version is gone, diff against empty so the full draft is surfaced.
let baseContent = ''
if (baseHash) {
try {
baseContent = (await ScriptService.getScriptByHash({ workspace, hash: baseHash })).content
} catch (e) {
console.error(`rebase_draft: could not fetch base version ${baseHash} for "${path}"`, e)
}
}
const yourChanges = createTwoFilesPatch(
'fork-base',
'your-draft',
baseContent,
draft.value,
'',
''
)
// Discard the stale draft rather than resetting it to latest: the next write
// re-bases on the current head, and a premature deploy fails cleanly ("no
// draft") instead of silently shipping the latest unchanged and losing the work.
await deleteGlobalDraft(workspace, 'script', path)
toolCallbacks.setToolStatus(toolId, {
content: `Discarded stale draft "${path}"`,
result: 'Rebased'
})
return JSON.stringify(
{
success: true,
message:
`Discarded the stale draft for "${path}". Your changes are in "your_changes" (a diff against the ` +
`version you forked from). Re-apply them with edit_script / write_script — the new draft will be ` +
`based on the latest deployed version (hash ${latest.hash}) — then deploy.`,
latest_hash: latest.hash,
your_changes: yourChanges
},
null,
2
)
}
async function rebaseFlowDraft(path: string, ctx: WriteDraftCtx): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const draft = await getGlobalDraft(workspace, 'flow', path)
if (!draft || draft.value === undefined || typeof draft.value === 'string') {
throw new Error(`No flow draft found for "${path}".`)
}
if (!(await FlowService.existsFlowByPath({ workspace, path }))) {
throw new Error(`Flow "${path}" is not deployed; there is no newer version to rebase onto.`)
}
const latest = await FlowService.getFlowByPath({ workspace, path })
const baseVersion = draft.parentVersionId
if (baseVersion != null && baseVersion === latest.version_id) {
const message = `Draft "${path}" is already based on the latest deployed version (${latest.version_id}).`
toolCallbacks.setToolStatus(toolId, { content: message })
return JSON.stringify({
success: true,
alreadyLatest: true,
latest_version: latest.version_id,
message
})
}
toolCallbacks.setToolStatus(toolId, { content: `Rebasing draft "${path}" onto latest...` })
// The draft's own changes vs its fork-base flow value, as a JSON diff for the
// model to replay. Best-effort: skip if the base version can't be fetched.
let baseValue: unknown = {}
if (baseVersion != null) {
try {
baseValue = (await FlowService.getFlowVersion({ workspace, version: baseVersion })).value
} catch (e) {
console.error(
`rebase_draft: could not fetch base flow version ${baseVersion} for "${path}"`,
e
)
}
}
const oursValue = (draft.value as FlowDraftValue).value
const yourChanges = createTwoFilesPatch(
'fork-base',
'your-draft',
JSON.stringify(baseValue, null, 2),
JSON.stringify(oursValue, null, 2),
'',
''
)
// Discard the stale draft (see rebaseScriptDraft): the next write re-bases on
// the current head, and a premature deploy fails cleanly instead of shipping
// the latest unchanged.
await deleteGlobalDraft(workspace, 'flow', path)
toolCallbacks.setToolStatus(toolId, {
content: `Discarded stale draft "${path}"`,
result: 'Rebased'
})
return JSON.stringify(
{
success: true,
message:
`Discarded the stale draft for "${path}". Your changes are in "your_changes" (a JSON diff against ` +
`the version you forked from). Re-apply them with the flow edit tools — the new draft will be ` +
`based on the latest deployed version (version ${latest.version_id}) — then deploy.`,
latest_version: latest.version_id,
your_changes: yourChanges
},
null,
2
)
}
async function rebaseAppDraft(path: string, ctx: WriteDraftCtx): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const draft = await getGlobalDraft(workspace, 'app', path)
if (!draft || !draft.value || typeof draft.value === 'string' || !('files' in draft.value)) {
throw new Error(`No app draft found for "${path}".`)
}
if (!(await AppService.existsApp({ workspace, path }))) {
throw new Error(`App "${path}" is not deployed; there is no newer version to rebase onto.`)
}
const deployed = await AppService.getAppByPath({ workspace, path })
const headVersion = deployed.versions?.[deployed.versions.length - 1]
const baseVersion = draft.parentVersionId
if (baseVersion != null && baseVersion === headVersion) {
const message = `Draft "${path}" is already based on the latest deployed version (${headVersion}).`
toolCallbacks.setToolStatus(toolId, { content: message })
return JSON.stringify({
success: true,
alreadyLatest: true,
latest_version: headVersion,
message
})
}
toolCallbacks.setToolStatus(toolId, { content: `Rebasing draft "${path}" onto latest...` })
// The draft's own changes vs its fork-base app source, as a JSON diff for the
// model to replay. Best-effort: skip if the base version can't be fetched.
const oursValue = draft.value as AppDraftValue
let baseSource: Pick<AppDraftValue, 'files' | 'runnables' | 'data'> = {
files: {},
runnables: {},
data: undefined
}
if (baseVersion != null) {
try {
const baseApp = await AppService.getAppByVersion({ workspace, id: baseVersion })
const base = appSourceToDraftValue(baseApp, baseApp)
baseSource = { files: base.files, runnables: base.runnables, data: base.data }
} catch (e) {
console.error(
`rebase_draft: could not fetch base app version ${baseVersion} for "${path}"`,
e
)
}
}
const yourChanges = createTwoFilesPatch(
'fork-base',
'your-draft',
JSON.stringify(baseSource, null, 2),
JSON.stringify(
{ files: oursValue.files, runnables: oursValue.runnables, data: oursValue.data },
null,
2
),
'',
''
)
// Discard the stale draft (see rebaseScriptDraft): the next write re-projects
// the deployed app into a fresh draft (re-pinning parent_version to the head),
// and a premature deploy fails cleanly instead of shipping the latest unchanged.
await deleteGlobalDraft(workspace, 'app', path)
toolCallbacks.setToolStatus(toolId, {
content: `Discarded stale draft "${path}"`,
result: 'Rebased'
})
return JSON.stringify(
{
success: true,
message:
`Discarded the stale draft for "${path}". Your changes are in "your_changes" (a JSON diff against ` +
`the version you forked from). Re-apply them with the app edit tools — the new draft will be based ` +
`on the latest deployed version (version ${headVersion}) — then deploy.`,
latest_version: headVersion,
your_changes: yourChanges
},
null,
2
)
}
// Flush a draft's pending editor autosave, then verify it actually landed before
// the caller re-reads the persisted draft. `flush()` resolves even when the save
// recorded a conflict (server has a newer version) or failed (network/5xx) — it
@@ -3731,11 +4022,18 @@ async function deployDraft(
path: string
trigger_kind?: TriggerKind
deployment_message?: string
force?: boolean
},
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks, sessionId } = ctx
const { type, path, trigger_kind: triggerKind, deployment_message: deploymentMessage } = args
const {
type,
path,
trigger_kind: triggerKind,
deployment_message: deploymentMessage,
force
} = args
if (type === 'trigger' && !triggerKind) {
throw new Error('trigger_kind is required when deploying a trigger.')
@@ -3777,6 +4075,19 @@ async function deployDraft(
// post-deploy draft delete doesn't drop an unsaved edit. `flush` always saves
// the parked value (like Ctrl/Cmd+S), since the user explicitly asked to deploy.
await flushDraftOrThrow({ workspace, itemKind: type, path: storagePath }, `${type} "${path}"`)
// Stale-draft guard: block when the draft was forked from an older deploy than
// the current head (unless force), pointing the model at rebase_draft.
if (type === 'script') {
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
? await ScriptService.getScriptByPath({ workspace, path })
: undefined
assertDraftBasedOnLatest('script', path, draft.parentHash, existing?.hash, force)
} else {
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
? await FlowService.getFlowByPath({ workspace, path })
: undefined
assertDraftBasedOnLatest('flow', path, draft.parentVersionId, existing?.version_id, force)
}
const draftOnly =
type === 'flow'
? !(await FlowService.existsFlowByPath({ workspace, path: storagePath }))
@@ -3845,6 +4156,20 @@ async function deployDraft(
// not the deployed app's nested `value` shape the shared raw-app
// deployer reads, so they deploy through the chat's own bundle path.
const appDraft = draft.value as AppDraftValue
// Stale-draft guard: only fetch the deployed head when the draft records
// a fork base to compare against (pre-feature drafts have none).
if (draft.parentVersionId != null) {
const deployedApp = (await AppService.existsApp({ workspace, path }))
? await AppService.getAppByPath({ workspace, path })
: undefined
assertDraftBasedOnLatest(
'app',
path,
draft.parentVersionId,
deployedApp?.versions?.[deployedApp.versions.length - 1],
force
)
}
const appValue: AppDraftValue = {
...appDraft,
files: { ...(appDraft.files ?? {}) },
@@ -66,7 +66,10 @@ function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue {
runnables: { ...(value.runnables ?? {}) },
data: value.data ?? { ...DEFAULT_RAW_APP_DATA },
policy: value.policy === undefined ? undefined : clone(value.policy),
custom_path: value.custom_path
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
}
}
@@ -135,6 +138,7 @@ function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceIt
summary: draft.summary,
language: draft.language,
value: draft.content,
parentHash: draft.parent_hash,
isDraft: true
}
}
@@ -144,6 +148,10 @@ function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem {
type: 'flow',
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,
@@ -160,6 +168,7 @@ function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceI
type: 'app',
path,
summary: value.summary,
parentVersionId: value.parent_version,
value,
isDraft: true
}
@@ -72,6 +72,9 @@ export type AppDraftValue = {
data?: any
policy?: Policy
custom_path?: string
// Fork base: the deployed app version this draft was started from, pinned at
// fork. The app analog of a script's parent_hash / a flow's version_id.
parent_version?: number
}
export type ResourceDraftState = {
@@ -99,6 +102,11 @@ export type WorkspaceItem = {
summary?: string
language?: ScriptLang
triggerKind?: TriggerKind
// Fork base: the deployed version this draft was started from, compared against
// the current deployed head to detect a stale draft. `parentHash` for scripts
// (script hash), `parentVersionId` for flows (flow_version id).
parentHash?: string
parentVersionId?: number
value?:
| string
| FlowDraftValue
@@ -24,6 +24,9 @@ export type FlowBuilderProps = {
disabledFlowInputs?: boolean
savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore
version?: number | undefined
/** flow_version the draft was forked from; when set, the deploy-time staleness
* check compares it (not the load-time head `version`) against the latest. */
draftBaseVersion?: number | undefined
draftTriggersFromUrl?: Trigger[] | undefined
selectedTriggerIndexFromUrl?: number | undefined
children?: import('svelte').Snippet
@@ -47,6 +47,12 @@ export function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
runnables: { ...(value.runnables ?? {}) },
data: normalizeRawAppData(value),
policy: app.policy ?? fallback?.policy,
custom_path: app.custom_path ?? fallback?.custom_path
custom_path: app.custom_path ?? fallback?.custom_path,
// Pin the fork base: a deployed app exposes `versions` (head = last); an
// existing draft already carries `parent_version` — preserve it.
parent_version:
app.parent_version ??
(Array.isArray(app.versions) ? app.versions[app.versions.length - 1] : undefined) ??
fallback?.parent_version
}
}
+1
View File
@@ -204,6 +204,7 @@ const DRAFT_COMPARE_IGNORED_FIELDS = [
'edited_by',
'workspace_id',
'version_id',
'parent_version',
'is_draft'
] as const
+7 -2
View File
@@ -207,9 +207,12 @@ export async function getDraftDiffValues(
draft_saved_at: _c,
no_deployed: _n,
other_drafts_users: _o,
version_id: _v,
...deployed
} = r
const draftValue = draft ?? deployed
// 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 }
} else if (kind === 'app' || kind === 'raw_app') {
// A never-deployed raw app has no `app` row; the backend resolves the
@@ -237,7 +240,9 @@ export async function getDraftDiffValues(
path: r.path,
custom_path: r.custom_path
}
const draftValue = r.draft ?? deployed
// Strip the draft's pinned fork-base `parent_version` (the deployed allowlist
// above already omits it) so it never renders as a spurious diff line.
const { parent_version: _pv, ...draftValue } = (r.draft ?? deployed) as any
return { deployed: draftOnly ? EMPTY_DEPLOYED.app!(draftValue) : deployed, draft: draftValue }
} else {
// Variables / resources / schedules / triggers: one overlay GET yields
@@ -49,6 +49,10 @@
let othersModalOpen = $state(false)
let draftSavedAt = $state<string | undefined>(undefined)
let deployedAt = $state<string | undefined>(undefined)
// The app_version the draft was forked from (pinned), + the deployed head, for
// the precise staleness check in DraftEditorModals (vs the drifting timestamp).
let draftBaseVersion = $state<number | undefined>(undefined)
let deployedHeadVersion = $state<number | undefined>(undefined)
/** Increments per `loadApp` call. Stale loads (e.g. when picker
* navigation races a draft-discard reload) bail at the next checkpoint
@@ -78,6 +82,11 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
// New-draft returns before the version assignment below, so clear the
// previous app's version-staleness inputs, else they bleed across the
// reused route and falsely trip the stale-draft modal.
draftBaseVersion = undefined
deployedHeadVersion = undefined
// Brand-new app: no deployed baseline, so never discard-on-equal.
deployedBaseline = undefined
const templatePath = page.url.searchParams.get('template')
@@ -275,6 +284,13 @@
// `no_deployed` — no baseline to be older than.
draftSavedAt = backendApp.draft_saved_at as string | undefined
deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined)
// `parent_version` rides on the persisted draft (pinned at fork); undefined
// for a pre-feature draft. Head = the last entry of the deployed `versions`.
draftBaseVersion = savedDraftApp?.parent_version
deployedHeadVersion =
backendApp.no_deployed || !backendApp.versions
? undefined
: backendApp.versions[backendApp.versions.length - 1]
const backendApp_ = structuredClone(stateSnapshot(backendApp))
savedApp = {
summary: backendApp_.summary,
@@ -318,6 +334,16 @@
})
}
}
// Pin the fork base for the stale-draft check: when seeding a draft from the
// deployed app (no own draft yet), stamp the deployed head version onto the
// draft value. An existing own draft already carries it (preserved by the
// value swap above). `parent_version` is in DRAFT_COMPARE_IGNORED_FIELDS, so it
// never trips the autosave no-op / "unsaved changes" comparison.
if (!hasOwnDraft && !backendApp.no_deployed && backendApp.value) {
const versions = (backendApp as { versions?: number[] }).versions
const head = Array.isArray(versions) ? versions[versions.length - 1] : undefined
if (head != null) (backendApp.value as App).parent_version = head
}
// Assign the fresh response onto `app`. The path-change $effect sets
// `app = undefined` first, unmounting AppEditor and releasing the UserDraft
// entry, so the remount starts fresh — no local discard is needed here
@@ -412,6 +438,8 @@
bind:othersModalOpen
{draftSavedAt}
{deployedAt}
{draftBaseVersion}
{deployedHeadVersion}
onLoadLatestDeploy={async () => {
if (!$workspaceStore) return
await runResetToDeployed({
@@ -56,6 +56,9 @@
let othersModalOpen = $state(false)
let draftSavedAt = $state<string | undefined>(undefined)
let deployedAt = $state<string | undefined>(undefined)
// The flow_version the draft was forked from (pinned, doesn't drift), for the
// precise staleness check in DraftEditorModals + FlowBuilder's deploy guard.
let draftBaseVersion = $state<number | undefined>(undefined)
// Editor-displayed path; defaults to the URL path. Cleared to '' in the
// `new_draft` branch so the Path widget's `initPath` seeds the friendly name.
let flowInitialPath = $state(page.params.path ?? '')
@@ -152,6 +155,11 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
// New-draft skips the deployed/draft fetches, so the version-staleness
// inputs are never reassigned — clear the previous flow's values, else they
// bleed across the reused route and falsely trip the stale-draft modal.
version = undefined
draftBaseVersion = undefined
// Brand-new flow: no deployed baseline, so never discard-on-equal.
deployedBaseline = undefined
// Suspend autosave around the bootstrap cascade: the Path widget's
@@ -357,6 +365,9 @@
// Layer the draft (`.draft`, if any) over the deployed payload at the field
// level. See /scripts/edit's loader for the rationale.
const { draft: draftFromBackend, ...deployedFlow } = backendFlow as any
// `version_id` rides on the persisted draft (pinned at fork); undefined for a
// pre-feature draft or when editing the deployed flow directly (no draft).
draftBaseVersion = draftFromBackend?.version_id as number | undefined
const effectiveFlow: Flow = draftFromBackend
? ({ ...deployedFlow, ...draftFromBackend } as Flow)
: (deployedFlow as Flow)
@@ -501,6 +512,8 @@
bind:othersModalOpen
{draftSavedAt}
{deployedAt}
{draftBaseVersion}
deployedHeadVersion={version}
onLoadLatestDeploy={async () => {
// stopSync-bracketed; see /scripts/edit's restoreDeployed for the race.
if (!$workspaceStore) return
@@ -563,6 +576,7 @@
{draftTriggersFromUrl}
{selectedTriggerIndexFromUrl}
{version}
{draftBaseVersion}
{loadedFromHistoryFromUrl}
/>
{/if}