mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
fix(ai): keep chat deploy value-only; handle draft-only rows and refresh draft counts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
069db3a2cf
commit
95942ca815
@@ -166,6 +166,10 @@ vi.mock('./rawAppBundlerBridge', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('$lib/workspaceDrafts.svelte', () => ({
|
||||
invalidateWorkspaceDrafts: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
globalTools,
|
||||
globalToolsFor,
|
||||
@@ -176,6 +180,7 @@ import {
|
||||
setOpenPreviewHandler
|
||||
} from './core'
|
||||
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
|
||||
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import { clearGlobalDrafts } from './userDraftAdapter'
|
||||
import { bundleRawAppDraft } from './rawAppBundlerBridge'
|
||||
import {
|
||||
@@ -929,6 +934,70 @@ describe('global AI tools', () => {
|
||||
).rejects.toThrow('trigger_kind is required')
|
||||
})
|
||||
|
||||
it('discards a draft-only item that has no separate draft row', async () => {
|
||||
// An item that exists ONLY as a draft_only row, with no draft-table entry
|
||||
// (e.g. created elsewhere) — the row itself is the draft content.
|
||||
await ScriptService.createScript({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/scripts/orphan',
|
||||
summary: 'Orphan',
|
||||
content: 'export async function main() { return 1 }',
|
||||
language: 'bun',
|
||||
draft_only: true
|
||||
} as any
|
||||
})
|
||||
expect(dbScriptDraft('f/scripts/orphan')).toBeUndefined()
|
||||
|
||||
const raw = await callGlobalTool('discard_local_draft', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/orphan'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toMatchObject({ success: true, type: 'script', path: 'f/scripts/orphan' })
|
||||
// draft_only with no deployed version -> the whole item is removed.
|
||||
expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/scripts/orphan'
|
||||
})
|
||||
})
|
||||
|
||||
it('deploys a chat-created script draft and clears the draft', async () => {
|
||||
const content = 'export async function main() { return 1 }'
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/to-deploy',
|
||||
summary: 'Deploy me',
|
||||
language: 'bun',
|
||||
content
|
||||
})
|
||||
expect(dbScriptDraft('f/scripts/to-deploy')).toBeDefined()
|
||||
|
||||
vi.mocked(invalidateWorkspaceDrafts).mockClear()
|
||||
const raw = await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/to-deploy'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toMatchObject({ success: true, type: 'script', path: 'f/scripts/to-deploy' })
|
||||
// Deploy writes a real (non-draft_only) version; the backend clears the draft.
|
||||
const createCalls = vi.mocked(ScriptService.createScript).mock.calls
|
||||
const lastCreate = createCalls[createCalls.length - 1]?.[0] as any
|
||||
expect(lastCreate.requestBody.draft_only).toBeFalsy()
|
||||
expect(lastCreate.requestBody.content).toBe(content)
|
||||
expect(dbScriptDraft('f/scripts/to-deploy')).toBeUndefined()
|
||||
expect(invalidateWorkspaceDrafts).toHaveBeenCalledWith(WORKSPACE)
|
||||
})
|
||||
|
||||
it('invalidates workspace draft counts after writing a draft', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/counted',
|
||||
summary: '',
|
||||
language: 'bun',
|
||||
content: 'export async function main() {}'
|
||||
})
|
||||
expect(invalidateWorkspaceDrafts).toHaveBeenCalledWith(WORKSPACE)
|
||||
})
|
||||
|
||||
it('preserves existing script metadata when writing over an existing item', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({
|
||||
path: 'f/scripts/existing',
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
import type { ContextElement } from '../context'
|
||||
import { getDatatableTools } from '../datatableTools'
|
||||
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import {
|
||||
@@ -2352,8 +2353,9 @@ function liveDraftItem(
|
||||
}
|
||||
|
||||
// The current chat draft for a script/flow: the live editor buffer if one is
|
||||
// open, else the DB draft (`.draft`). `undefined` when no draft exists (or the
|
||||
// item doesn't exist at all).
|
||||
// open, else the DB draft (`.draft`), else the `draft_only` row itself (a
|
||||
// never-deployed item whose row IS the draft content, even when no separate
|
||||
// draft row exists). `undefined` when no draft exists (or the item doesn't).
|
||||
async function loadDbDraftItem(
|
||||
workspace: string,
|
||||
type: DbDraftKind,
|
||||
@@ -2364,14 +2366,16 @@ async function loadDbDraftItem(
|
||||
try {
|
||||
if (type === 'script') {
|
||||
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
|
||||
if (!script.draft) return undefined
|
||||
const item = scriptToItem(script.draft as NewScript, true)
|
||||
const src = script.draft ?? (script.draft_only ? script : undefined)
|
||||
if (!src) return undefined
|
||||
const item = scriptToItem(src as NewScript, true)
|
||||
item.isDraft = true
|
||||
return item
|
||||
}
|
||||
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
|
||||
if (!flow.draft) return undefined
|
||||
const item = flowToItem(flow.draft as Flow, true)
|
||||
const src = flow.draft ?? (flow.draft_only ? flow : undefined)
|
||||
if (!src) return undefined
|
||||
const item = flowToItem(src as Flow, true)
|
||||
item.isDraft = true
|
||||
return item
|
||||
} catch {
|
||||
@@ -2399,6 +2403,8 @@ async function saveScriptDbDraft(
|
||||
})
|
||||
}
|
||||
await DraftService.createDraft({ workspace, requestBody: { path, typ: 'script', value: draft } })
|
||||
// Refresh mounted draft-count consumers (Drafts banner, compare page).
|
||||
invalidateWorkspaceDrafts(workspace)
|
||||
}
|
||||
|
||||
// Persist a flow DB draft, creating the backing `draft_only` row first when the
|
||||
@@ -2424,6 +2430,7 @@ async function saveFlowDbDraft(
|
||||
})
|
||||
}
|
||||
await DraftService.createDraft({ workspace, requestBody: { path, typ: 'flow', value: draft } })
|
||||
invalidateWorkspaceDrafts(workspace)
|
||||
}
|
||||
|
||||
// Delete a script/flow DB draft. If the item exists ONLY as a draft
|
||||
@@ -2459,6 +2466,7 @@ async function deleteScriptFlowDbDraft(
|
||||
|
||||
// Reset any open live editor (and clear a stale localStorage buffer).
|
||||
deleteGlobalDraft(workspace, type, path)
|
||||
invalidateWorkspaceDrafts(workspace)
|
||||
}
|
||||
|
||||
async function writeScriptDraft(
|
||||
@@ -3461,6 +3469,9 @@ async function deployDraft(
|
||||
|
||||
switch (type) {
|
||||
case 'script': {
|
||||
// Script deploy writes a complete new version (createScript) — omitted
|
||||
// fields are reset, not inherited. The AI only changes value/summary, so
|
||||
// carry ALL other metadata forward from the currently-deployed version.
|
||||
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
|
||||
? await ScriptService.getScriptByPath({ workspace, path })
|
||||
: undefined
|
||||
@@ -3477,6 +3488,7 @@ async function deployDraft(
|
||||
break
|
||||
}
|
||||
case 'flow': {
|
||||
// Flow update is a full replace; carry metadata forward from deployed.
|
||||
const flowDraft = draft.value as FlowDraftValue
|
||||
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
|
||||
? await FlowService.getFlowByPath({ workspace, path })
|
||||
@@ -3626,6 +3638,9 @@ async function deployDraft(
|
||||
}
|
||||
|
||||
deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true })
|
||||
// Deploying create/update-deletes the DB draft server-side; refresh mounted
|
||||
// draft-count consumers so the Drafts banner/count drops immediately.
|
||||
invalidateWorkspaceDrafts(workspace)
|
||||
|
||||
// Reload the session preview if it's open on the deployed item. Map the
|
||||
// deploy type to the preview kind — a raw app deploys under 'app' but the
|
||||
|
||||
Reference in New Issue
Block a user