mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 00:01:55 +00:00
fix: normalize ai draft data
This commit is contained in:
@@ -1080,6 +1080,39 @@ describe('global AI tools', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('filters list results against DB script draft summaries after write_script', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/stale-list-summary',
|
||||
summary: 'Fresh DB draft summary',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return "fresh" }'
|
||||
})
|
||||
|
||||
vi.mocked(ScriptService.listScripts).mockResolvedValueOnce([
|
||||
{
|
||||
path: 'f/scripts/stale-list-summary',
|
||||
summary: 'Old deployed summary',
|
||||
language: 'bun',
|
||||
has_draft: true
|
||||
}
|
||||
] as any)
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script'],
|
||||
query: 'Fresh DB draft'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
path: 'f/scripts/stale-list-summary',
|
||||
summary: 'Fresh DB draft summary',
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
expect(raw).not.toContain('Old deployed summary')
|
||||
})
|
||||
|
||||
it('lists and edits the live script editor draft through its effective path', async () => {
|
||||
UserDraft.save(
|
||||
'script',
|
||||
@@ -1242,11 +1275,37 @@ describe('global AI tools', () => {
|
||||
path: 'f/scripts/discard-me',
|
||||
keepCaptures: true
|
||||
})
|
||||
expect(vi.mocked(ScriptService.deleteScriptByPath).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(DraftService.deleteDraft).mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(
|
||||
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a DB draft when draft-only anchor deletion fails', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/blocked-discard',
|
||||
summary: 'Temporary draft',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return 1 }'
|
||||
})
|
||||
|
||||
vi.mocked(ScriptService.deleteScriptByPath).mockRejectedValueOnce(
|
||||
new Error('deployment rules blocked deletion')
|
||||
)
|
||||
|
||||
await expect(
|
||||
callGlobalTool('discard_local_draft', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/blocked-discard'
|
||||
})
|
||||
).rejects.toThrow('deployment rules blocked deletion')
|
||||
|
||||
expect(DraftService.deleteDraft).not.toHaveBeenCalled()
|
||||
expect(scriptDrafts.has('f/scripts/blocked-discard')).toBe(true)
|
||||
})
|
||||
|
||||
it('requires trigger_kind when discarding a trigger draft', async () => {
|
||||
await expect(
|
||||
callGlobalTool('discard_local_draft', {
|
||||
|
||||
@@ -94,10 +94,12 @@ import {
|
||||
deployAppDraft,
|
||||
deployFlowDraft,
|
||||
deployScriptDraft,
|
||||
type DraftFlagged,
|
||||
flowToItem,
|
||||
isDbDraftWorkspaceItemType,
|
||||
loadAppDraftValue,
|
||||
loadAppValueForRead,
|
||||
loadDbDraftItem,
|
||||
loadFlowWithDbDraft,
|
||||
loadScriptWithDbDraft,
|
||||
loadWorkspaceDraft,
|
||||
@@ -656,6 +658,28 @@ function itemMatches(
|
||||
)
|
||||
}
|
||||
|
||||
type ListedDbDraftType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
|
||||
|
||||
async function hydrateListedDbDraft(
|
||||
workspace: string,
|
||||
type: ListedDbDraftType,
|
||||
item: DraftFlagged & { path: string },
|
||||
fallback: WorkspaceItem
|
||||
): Promise<WorkspaceItem> {
|
||||
if (!item.has_draft && !item.draft_only) return fallback
|
||||
const draft = await loadDbDraftItem(workspace, type, item.path)
|
||||
return draft ? { ...draft, value: undefined } : fallback
|
||||
}
|
||||
|
||||
async function hydrateListedDbDrafts<T extends DraftFlagged & { path: string }>(
|
||||
workspace: string,
|
||||
type: ListedDbDraftType,
|
||||
rows: T[],
|
||||
toItem: (row: T) => WorkspaceItem
|
||||
): Promise<WorkspaceItem[]> {
|
||||
return Promise.all(rows.map((row) => hydrateListedDbDraft(workspace, type, row, toItem(row))))
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a flow workspace item into the compact response we send to the model:
|
||||
* rawscript content is replaced with `inline_script.<moduleId>` placeholders.
|
||||
@@ -1112,7 +1136,11 @@ async function listWorkspaceItems(
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
for (const script of scripts) items.push(scriptToItem(script, false))
|
||||
items.push(
|
||||
...(await hydrateListedDbDrafts(workspace, 'script', scripts, (script) =>
|
||||
scriptToItem(script, false)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
if (types.includes('flow')) {
|
||||
@@ -1123,7 +1151,9 @@ async function listWorkspaceItems(
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
for (const flow of flows) items.push(flowToItem(flow, false))
|
||||
items.push(
|
||||
...(await hydrateListedDbDrafts(workspace, 'flow', flows, (flow) => flowToItem(flow, false)))
|
||||
)
|
||||
}
|
||||
|
||||
if (types.includes('schedule')) {
|
||||
@@ -1171,7 +1201,9 @@ async function listWorkspaceItems(
|
||||
perPage,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
for (const app of apps) items.push(appToItem(app, false))
|
||||
items.push(
|
||||
...(await hydrateListedDbDrafts(workspace, 'app', apps, (app) => appToItem(app, false)))
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { appSourceToRawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
|
||||
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
@@ -148,30 +149,30 @@ export async function deleteDbDraftAndDraftOnlyAnchor(
|
||||
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
|
||||
? await ScriptService.getScriptByPathWithDraft({ workspace, path })
|
||||
: undefined
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
if (existing?.draft_only) {
|
||||
await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true })
|
||||
}
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
break
|
||||
}
|
||||
case 'flow': {
|
||||
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
|
||||
? await FlowService.getFlowByPathWithDraft({ workspace, path })
|
||||
: undefined
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
if (existing?.draft_only) {
|
||||
await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true })
|
||||
}
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
break
|
||||
}
|
||||
case 'app': {
|
||||
const existing = (await AppService.existsApp({ workspace, path }))
|
||||
? await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
: undefined
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
if (existing?.draft_only) {
|
||||
await AppService.deleteApp({ workspace, path })
|
||||
}
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -528,36 +529,8 @@ async function saveAppDraftToDb(
|
||||
await createDbDraft(workspace, 'app', path, appDraftToDbValue(path, value))
|
||||
}
|
||||
|
||||
function normalizeRawAppData(value: Record<string, any>): AppDraftValue['data'] {
|
||||
if (value.data?.creation) {
|
||||
return {
|
||||
tables: value.data.tables ?? [],
|
||||
datatable: value.data.creation.datatable,
|
||||
schema: value.data.creation.schema
|
||||
}
|
||||
}
|
||||
if (value.data) {
|
||||
return value.data
|
||||
}
|
||||
if (value.datatables) {
|
||||
return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables }
|
||||
}
|
||||
if (value.dataTableRefs) {
|
||||
return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs }
|
||||
}
|
||||
return { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
|
||||
function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
|
||||
const value = (app.value ?? {}) as Record<string, any>
|
||||
return {
|
||||
summary: app.summary ?? '',
|
||||
files: { ...(value.files ?? {}) },
|
||||
runnables: { ...(value.runnables ?? {}) },
|
||||
data: normalizeRawAppData(value),
|
||||
policy: app.policy ?? fallback?.policy,
|
||||
custom_path: app.custom_path ?? fallback?.custom_path
|
||||
}
|
||||
return appSourceToRawAppDraft(app, fallback)
|
||||
}
|
||||
|
||||
function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { DEFAULT_DATA, type RawAppData } from './dataTableRefUtils'
|
||||
|
||||
// The raw-app draft shape stored under `UserDraft<RawAppDraft>`.
|
||||
export type RawAppDraft = {
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, any>
|
||||
data: RawAppData
|
||||
summary: string
|
||||
policy?: any
|
||||
custom_path?: string
|
||||
}
|
||||
|
||||
function normalizeRawAppData(value: Record<string, any>): RawAppData {
|
||||
if (value.data) {
|
||||
if (value.data.creation) {
|
||||
return {
|
||||
tables: value.data.tables ?? [],
|
||||
datatable: value.data.creation.datatable,
|
||||
schema: value.data.creation.schema
|
||||
}
|
||||
}
|
||||
return value.data
|
||||
}
|
||||
if (value.datatables) {
|
||||
return { ...DEFAULT_DATA, tables: value.datatables }
|
||||
}
|
||||
if (value.dataTableRefs) {
|
||||
return { ...DEFAULT_DATA, tables: value.dataTableRefs }
|
||||
}
|
||||
return { ...DEFAULT_DATA }
|
||||
}
|
||||
|
||||
export function appSourceToRawAppDraft(app: any, fallback?: any): RawAppDraft {
|
||||
const value = (app.value ?? {}) as Record<string, any>
|
||||
return {
|
||||
summary: app.summary ?? '',
|
||||
files: { ...(value.files ?? {}) },
|
||||
runnables: { ...(value.runnables ?? {}) },
|
||||
data: normalizeRawAppData(value),
|
||||
policy: app.policy ?? fallback?.policy,
|
||||
custom_path: app.custom_path ?? fallback?.custom_path
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type RuntimeRawApp,
|
||||
type RawAppDraft
|
||||
} from './appDraftCodec'
|
||||
import { appSourceToRawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
|
||||
|
||||
function runtime(over: Partial<RuntimeRawApp> = {}): RuntimeRawApp {
|
||||
return {
|
||||
@@ -59,3 +60,49 @@ describe('appDraftCodec — custom_path round-trip', () => {
|
||||
expect(applyDraftToRuntimeRawApp(base, dv).custom_path).toBe('existing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('appSourceToRawAppDraft', () => {
|
||||
it('unwraps DB draft wrappers instead of treating the wrapper as app files', () => {
|
||||
const draft = appSourceToRawAppDraft(
|
||||
{
|
||||
summary: 'draft app',
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'export default function App() { return "draft" }' },
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: { language: 'bun', content: 'export async function main() {}' }
|
||||
}
|
||||
},
|
||||
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
|
||||
},
|
||||
policy: { execution_mode: 'anonymous' },
|
||||
custom_path: 'draft-url'
|
||||
},
|
||||
{
|
||||
summary: 'deployed app',
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'deployed' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
},
|
||||
policy: { execution_mode: 'publisher' },
|
||||
custom_path: 'deployed-url'
|
||||
}
|
||||
)
|
||||
|
||||
expect(draft).toEqual({
|
||||
summary: 'draft app',
|
||||
files: { '/src/App.tsx': 'export default function App() { return "draft" }' },
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: { language: 'bun', content: 'export async function main() {}' }
|
||||
}
|
||||
},
|
||||
data: { tables: ['orders'], datatable: 'db', schema: 'public' },
|
||||
policy: { execution_mode: 'anonymous' },
|
||||
custom_path: 'draft-url'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import type { RawAppData } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import type { RawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
|
||||
|
||||
// The raw-app draft shape stored under `UserDraft<RawAppDraft>` — matches the
|
||||
// regular `/apps_raw/edit` route's UserDraft handle exactly. The chat's
|
||||
// `userDraftAdapter.saveGlobalAppDraft` writes through the same shape, so
|
||||
// session previews and the chat round-trip identically.
|
||||
export type RawAppDraft = {
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, any>
|
||||
data: RawAppData
|
||||
summary: string
|
||||
policy?: any
|
||||
custom_path?: string
|
||||
}
|
||||
export type { RawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
|
||||
|
||||
// The shape `runtime.rawApp.val` actually holds (see SessionRuntime in
|
||||
// sessionRuntime.svelte.ts). Adds `path` (a key, not a draft field) and
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '$lib/gen'
|
||||
import type { HiddenRunnable } from '$lib/components/apps/types'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { appSourceToRawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { emptySchema, type StateStore } from '$lib/utils'
|
||||
import {
|
||||
@@ -460,30 +461,15 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
draft: result.draft,
|
||||
custom_path: result.custom_path
|
||||
}
|
||||
const sourceValue: any = result.draft ?? result.value
|
||||
let data: RawAppData = { ...DEFAULT_DATA }
|
||||
if (sourceValue?.data) {
|
||||
const d = sourceValue.data
|
||||
if (d.creation) {
|
||||
data = {
|
||||
tables: d.tables ?? [],
|
||||
datatable: d.creation.datatable,
|
||||
schema: d.creation.schema
|
||||
}
|
||||
} else {
|
||||
data = d
|
||||
}
|
||||
} else if (sourceValue?.datatables) {
|
||||
data = { ...DEFAULT_DATA, tables: sourceValue.datatables }
|
||||
}
|
||||
const sourceDraft = appSourceToRawAppDraft(result.draft ?? result, result)
|
||||
const runtimeValue = {
|
||||
files: (sourceValue?.files ?? {}) as Record<string, string>,
|
||||
runnables: (sourceValue?.runnables ?? {}) as Record<string, any>,
|
||||
data,
|
||||
policy: result.policy,
|
||||
summary: result.summary ?? '',
|
||||
files: sourceDraft.files,
|
||||
runnables: sourceDraft.runnables,
|
||||
data: sourceDraft.data,
|
||||
policy: sourceDraft.policy,
|
||||
summary: sourceDraft.summary,
|
||||
path: result.path,
|
||||
custom_path: result.custom_path
|
||||
custom_path: sourceDraft.custom_path
|
||||
}
|
||||
UserDraft.save('raw_app', path, runtimeRawAppToDraft(runtimeValue), { workspace })
|
||||
rawApp.val = runtimeValue
|
||||
|
||||
Reference in New Issue
Block a user