refactor: remove global ai draft fallback store

This commit is contained in:
centdix
2026-05-19 18:06:02 +02:00
parent 110963748e
commit 43be4d8b2d
8 changed files with 140 additions and 379 deletions
@@ -94,13 +94,19 @@ vi.mock('$lib/gen', async () => {
existsApp: vi.fn(async () => false)
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: vi.fn(async () => false)
existsVariable: vi.fn(async () => false),
getVariable: vi.fn(async ({ path }: { path: string }) => ({
path,
value: 'super-secret-token',
is_secret: true,
description: 'API key'
})),
listVariable: vi.fn(async () => [])
})
}
})
import { globalTools, prepareGlobalUserMessage } from './core'
import { globalDraftStore } from './draftStore.svelte'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
import type { Tool, ToolCallbacks } from '../shared'
@@ -135,12 +141,18 @@ async function callGlobalTool(
describe('global AI tools', () => {
beforeEach(() => {
globalDraftStore.clearDrafts(WORKSPACE)
__resetUserDraftForTesting()
localStorage.clear()
vi.clearAllMocks()
})
it('does not expose resource or variable draft writers until their UserDraft contracts are complete', () => {
const toolNames = globalTools.map((tool) => tool.def.function.name)
expect(toolNames).not.toContain('write_resource')
expect(toolNames).not.toContain('write_variable')
})
it('writes script drafts into the shared UserDraft store', async () => {
const content = 'export async function main() {\n\treturn "hello"\n}'
@@ -273,14 +285,7 @@ describe('global AI tools', () => {
])
})
it('redacts variable draft values when reading workspace items', async () => {
await callGlobalTool('write_variable', {
path: 'f/secrets/api_key',
value: 'super-secret-token',
is_secret: true,
description: 'API key'
})
it('redacts existing variable values when reading workspace items', async () => {
const raw = await callGlobalTool('read_workspace_item', {
type: 'variable',
path: 'f/secrets/api_key'
@@ -292,7 +297,7 @@ describe('global AI tools', () => {
type: 'variable',
path: 'f/secrets/api_key',
summary: 'API key',
isDraft: true
isDraft: false
})
})
@@ -61,14 +61,11 @@ import {
} from '../shared'
import type { ContextElement } from '../context'
import {
resourceRequestSchema,
scheduleRequestSchema,
triggerRequestSchemas,
variableRequestSchema
triggerRequestSchemas
} from '../workspaceToolsZod.gen'
import {
getWorkspaceItemKey,
globalDraftStore,
TRIGGER_KINDS,
type AppDraftValue,
type FlowDraftValue,
@@ -76,7 +73,7 @@ import {
type TriggerKind,
type WorkspaceItem,
type WorkspaceItemType
} from './draftStore.svelte'
} from './workspaceItems'
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
import {
deleteGlobalDraft,
@@ -279,10 +276,6 @@ const writeTriggerSchema = z.object({
)
})
const writeResourceSchema = resourceRequestSchema
const writeVariableSchema = variableRequestSchema
const searchResourceTypesSchema = z.object({
query: z.string().describe('Substring to match against resource type names.'),
limit: z
@@ -474,10 +467,10 @@ const initAppSchema = z.object({
const GLOBAL_SYSTEM_PROMPT = `You are Windmill's global workspace assistant.
You can inspect workspace scripts, flows, schedules, triggers, resources, variables, and apps, then create draft changes in the frontend AI draft store.
You can inspect workspace scripts, flows, schedules, triggers, resources, variables, and apps, then create draft changes in the frontend AI draft store for supported item types.
Important rules:
- write_{script,flow,schedule,trigger,resource,variable} create or overwrite drafts. They do not save, deploy, or mutate workspace items.
- write_{script,flow,schedule,trigger} create or overwrite drafts. They do not save, deploy, or mutate workspace items.
- edit_script and patch_flow_json apply small exact-text edits and save the result as a draft. Prefer them for localized changes; use write_* for large rewrites.
- For flows specifically: read_workspace_item and patch_flow_json work on a COMPACT view where rawscript module bodies are replaced with the placeholder "inline_script.<moduleId>". Use read_flow_module_code / set_flow_module_code to inspect or overwrite an inline script body; use patch_flow_json for structural edits.
- deploy_workspace_item persists a draft to the workspace via the real backend create/update API and removes the draft. Requires user confirmation. Only call after the user has reviewed the draft and explicitly asked to deploy.
@@ -943,31 +936,6 @@ const triggerServices: Record<TriggerKind, TriggerService> = {
}
}
async function workspaceItemExists(
type: WorkspaceItemType,
path: string,
workspace: string,
triggerKind?: TriggerKind
): Promise<boolean> {
switch (type) {
case 'script':
return ScriptService.existsScriptByPath({ workspace, path })
case 'flow':
return FlowService.existsFlowByPath({ workspace, path })
case 'schedule':
return ScheduleService.existsSchedule({ workspace, path })
case 'trigger':
if (!triggerKind) return false
return triggerServices[triggerKind].exists({ workspace, path })
case 'resource':
return ResourceService.existsResource({ workspace, path })
case 'variable':
return VariableService.existsVariable({ workspace, path })
case 'app':
return AppService.existsApp({ workspace, path })
}
}
async function readWorkspaceItem(
type: WorkspaceItemType,
path: string,
@@ -1168,14 +1136,11 @@ ${getRawAppPrompt()}`
}
function getResourceInstructions(): string {
return `# Global draft resource & variable instructions
return `# Resource & variable reference
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
- A resource draft is a workspace item: \`{ type: 'resource', path, summary?, value, isDraft }\`. \`value\` is a CreateResource body: \`{ path, value, description?, resource_type, labels? }\` where the inner \`value\` is the resource type's data shape.
- A variable draft is a workspace item: \`{ type: 'variable', path, summary?, value, isDraft }\`. \`value\` is a CreateVariable body: \`{ path, value, is_secret, description, account?, is_oauth?, expires_at?, labels? }\`.
- For secret fields in a resource value, do NOT inline the raw secret. Create a Variable first with \`is_secret: true\`, then in the resource value reference it as \`"$var:path/to/variable"\`.
- Global mode can inspect resources and variables, but cannot create resource or variable drafts until their editor-native UserDraft contracts are complete.
- For secret fields in a resource value, do NOT inline the raw secret. Reference variables as \`"$var:path/to/variable"\`.
- Reference formats inside resource values: \`$var:g/all/name\` (global), \`$var:u/user/name\` (user), \`$var:f/folder/name\` (folder). Reference another resource with \`$res:path/to/resource\`.
- When deploying drafts that depend on each other (e.g., a resource and the variables it references), deploy the variables first.
- Use \`search_resource_types\` to discover valid \`resource_type\` names and their JSON Schemas. Match the resource value to that schema.
- For OAuth resources, the \`is_oauth: true\` flag is managed by Windmill's OAuth flow; global mode generally creates manual resources, not OAuth ones.
@@ -1476,59 +1441,13 @@ export const globalTools: Tool<{}>[] = [
return deleteWorkspaceItem(parsed, ctx)
}
},
{
def: createToolDef(
writeResourceSchema,
'write_resource',
'Create or overwrite an AI draft resource. Does not save or deploy. Reference secret values via $var:path/to/variable; create the variable separately with write_variable.',
{ strict: false }
),
showDetails: true,
streamArguments: true,
showFade: true,
fn: async (ctx) => {
const parsed = writeResourceSchema.parse(ctx.args)
return writeFallbackDraft(
{
type: 'resource',
path: parsed.path,
summary: parsed.description,
value: parsed,
isDraft: true
},
ctx
)
}
},
{
def: createToolDef(
writeVariableSchema,
'write_variable',
'Create or overwrite an AI draft variable. Does not save or deploy. Use is_secret: true for secret values. After deploy, reference from a resource as $var:path/to/variable.',
{ strict: false }
),
showDetails: true,
streamArguments: true,
showFade: true,
fn: async (ctx) => {
const parsed = writeVariableSchema.parse(ctx.args)
return writeFallbackDraft(
{
type: 'variable',
path: parsed.path,
summary: parsed.description,
value: parsed,
isDraft: true
},
ctx
)
}
},
// TODO: Re-enable write_resource/write_variable tools after resource and
// variable use self-contained editor-native UserDraft values.
{
def: createToolDef(
searchResourceTypesSchema,
'search_resource_types',
'Search for resource types in the workspace by substring. Returns names, descriptions, and JSON Schemas — use this before write_resource to know what shape value should have.'
'Search for resource types in the workspace by substring. Returns names, descriptions, and JSON Schemas.'
),
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsed = searchResourceTypesSchema.parse(args)
@@ -1705,7 +1624,7 @@ function finishDraftWrite(item: WorkspaceItem, exists: boolean, ctx: WriteDraftC
{
success: true,
message: `${verb} AI draft ${item.type} "${item.path}". The workspace was not saved or deployed.`,
item: item.type === 'variable' ? serializeWorkspaceItemForRead(item) : item
item
},
null,
2
@@ -1871,20 +1790,6 @@ async function writeTriggerDraft(
)
}
async function writeFallbackDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise<string> {
const { workspace } = ctx
startDraftWrite(ctx, item.type, item.path)
const existingDraft =
getGlobalDraft(workspace, item.type, item.path, item.triggerKind) !== undefined
const backendExists = existingDraft
? false
: await workspaceItemExists(item.type, item.path, workspace, item.triggerKind)
const stored = globalDraftStore.setDraft(workspace, item)
return finishDraftWrite(stored, existingDraft || backendExists, ctx)
}
function saveAppDraft(workspace: string, path: string, value: AppDraftValue): WorkspaceItem {
UserDraft.save('raw_app', path, normalizeAppDraftValue(value), { workspace })
return getRequiredGlobalDraft(workspace, 'app', path)
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Flow, NewScript, Script } from '$lib/gen/types.gen'
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
import type { WorkspaceItem } from './draftStore.svelte'
import type { WorkspaceItem } from './workspaceItems'
describe('global AI deploy request builders', () => {
it('preserves existing script metadata while replacing draft-controlled fields', () => {
@@ -1,5 +1,5 @@
import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen'
import type { FlowDraftValue, WorkspaceItem } from './draftStore.svelte'
import type { FlowDraftValue, WorkspaceItem } from './workspaceItems'
type ScriptWithDeployMetadata = Script & Partial<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
@@ -1,192 +0,0 @@
import type {
AzureTriggerData,
CreateResource,
CreateVariable,
FlowValue,
GcpTriggerData,
NewHttpTrigger,
NewKafkaTrigger,
NewMqttTrigger,
NewNatsTrigger,
NewPostgresTrigger,
NewSchedule,
NewSqsTrigger,
NewWebsocketTrigger,
Policy,
ScriptLang
} from '$lib/gen/types.gen'
/**
* Flow draft value. Mirrors what the backend's create/update flow API expects
* — the OpenFlow value, plus the inputs schema and (optional) groups.
*
* Schema and groups are split out from FlowValue intentionally so that
* deploy_workspace_item can preserve them through the draft → workspace
* round-trip; an earlier version dropped them on every deploy.
*/
export type FlowDraftValue = {
value: FlowValue
schema?: Record<string, any> | null
groups?: NonNullable<FlowValue['groups']> | null
}
export const TRIGGER_KINDS = [
'http',
'websocket',
'kafka',
'nats',
'postgres',
'mqtt',
'sqs',
'gcp',
'azure'
] as const
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
export type TriggerRequestBody =
| NewHttpTrigger
| NewWebsocketTrigger
| NewKafkaTrigger
| NewNatsTrigger
| NewPostgresTrigger
| NewMqttTrigger
| NewSqsTrigger
| GcpTriggerData
| AzureTriggerData
export type WorkspaceItemType =
| 'script'
| 'flow'
| 'schedule'
| 'trigger'
| 'resource'
| 'variable'
| 'app'
export type AppDraftValue = {
summary?: string
files: Record<string, string>
runnables: Record<string, any>
data?: any
policy?: Policy
custom_path?: string
}
export type WorkspaceItem = {
type: WorkspaceItemType
path: string
summary?: string
language?: ScriptLang
triggerKind?: TriggerKind
value?:
| string
| FlowDraftValue
| NewSchedule
| TriggerRequestBody
| CreateResource
| CreateVariable
| AppDraftValue
isDraft: boolean
}
export function getWorkspaceItemKey(
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
if (type === 'trigger') {
return `trigger:${triggerKind ?? ''}:${path}`
}
return `${type}:${path}`
}
function clone<T>(value: T): T {
return structuredClone($state.snapshot(value)) as T
}
class GlobalDraftStore {
private drafts = $state<Record<string, Record<string, WorkspaceItem>>>({})
private getWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
return this.drafts[workspace] ?? {}
}
private ensureWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
if (!this.drafts[workspace]) {
this.drafts[workspace] = {}
}
return this.drafts[workspace]
}
listDrafts(workspace: string): WorkspaceItem[] {
return Object.values(this.getWorkspaceDrafts(workspace)).map(clone)
}
getDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
const draft = this.getWorkspaceDrafts(workspace)[getWorkspaceItemKey(type, path, triggerKind)]
return draft ? clone(draft) : undefined
}
setDraft(workspace: string, item: WorkspaceItem): WorkspaceItem {
const stored: WorkspaceItem = { ...clone(item), isDraft: true }
this.ensureWorkspaceDrafts(workspace)[
getWorkspaceItemKey(item.type, item.path, item.triggerKind)
] = stored
return clone(stored)
}
deleteDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): void {
const drafts = this.drafts[workspace]
if (!drafts) return
delete drafts[getWorkspaceItemKey(type, path, triggerKind)]
if (Object.keys(drafts).length === 0) {
delete this.drafts[workspace]
}
}
clearDrafts(workspace: string): void {
delete this.drafts[workspace]
}
getScriptDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'script', path)
}
getFlowDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'flow', path)
}
getScheduleDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'schedule', path)
}
getTriggerDraft(workspace: string, kind: TriggerKind, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'trigger', path, kind)
}
getResourceDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'resource', path)
}
getVariableDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'variable', path)
}
getAppDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'app', path)
}
}
export const globalDraftStore = new GlobalDraftStore()
@@ -1,56 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { globalDraftStore } from './draftStore.svelte'
const WORKSPACE_A = 'draft-store-test-a'
const WORKSPACE_B = 'draft-store-test-b'
function clearTestDrafts() {
globalDraftStore.clearDrafts(WORKSPACE_A)
globalDraftStore.clearDrafts(WORKSPACE_B)
}
describe('globalDraftStore', () => {
beforeEach(clearTestDrafts)
it('lists and reads drafts only from the requested workspace', () => {
globalDraftStore.setDraft(WORKSPACE_A, {
type: 'script',
path: 'f/shared/path',
language: 'bun',
value: 'export async function main() {}',
isDraft: true
})
expect(globalDraftStore.getDraft(WORKSPACE_A, 'script', 'f/shared/path')?.value).toBe(
'export async function main() {}'
)
expect(globalDraftStore.getDraft(WORKSPACE_B, 'script', 'f/shared/path')).toBeUndefined()
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(1)
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
})
it('deletes and clears drafts only from the requested workspace', () => {
globalDraftStore.setDraft(WORKSPACE_A, {
type: 'flow',
path: 'f/shared/path',
value: { value: { modules: [] }, schema: null, groups: null },
isDraft: true
})
globalDraftStore.setDraft(WORKSPACE_B, {
type: 'flow',
path: 'f/shared/path',
value: { value: { modules: [] }, schema: { workspace: WORKSPACE_B }, groups: null },
isDraft: true
})
globalDraftStore.deleteDraft(WORKSPACE_A, 'flow', 'f/shared/path')
expect(globalDraftStore.getDraft(WORKSPACE_A, 'flow', 'f/shared/path')).toBeUndefined()
expect(globalDraftStore.getDraft(WORKSPACE_B, 'flow', 'f/shared/path')).toBeDefined()
globalDraftStore.clearDrafts(WORKSPACE_B)
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(0)
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
})
})
@@ -2,13 +2,12 @@ import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
import { UserDraft, type UserDraftItemKind, type UserDraftListEntry } from '$lib/userDraft.svelte'
import {
getWorkspaceItemKey,
globalDraftStore,
type AppDraftValue,
type TriggerRequestBody,
type TriggerKind,
type WorkspaceItem,
type WorkspaceItemType
} from './draftStore.svelte'
} from './workspaceItems'
type SharedWorkspaceItemType = 'script' | 'flow' | 'app' | 'schedule' | 'trigger'
@@ -53,6 +52,11 @@ const SHARED_DRAFT_KINDS = [
] as const satisfies UserDraftItemKind[]
const DEFAULT_APP_DATA = { tables: [], datatable: undefined, schema: undefined }
// TODO: Add resource and variable here only after their editor-native
// UserDraft contracts are self-contained. Resource drafts currently need
// resource_type outside the draft value, and new variable drafts do not have
// a complete persisted/live empty-path contract.
function clone<T>(value: T): T {
return structuredClone(value) as T
}
@@ -221,19 +225,14 @@ export function getGlobalDraft(
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
if (isSharedWorkspaceItemType(type)) {
const shared = getSharedDraft(workspace, type, path, triggerKind)
if (shared) return shared
return getSharedDraft(workspace, type, path, triggerKind)
}
return globalDraftStore.getDraft(workspace, type, path, triggerKind)
return undefined
}
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
const drafts = new Map<string, WorkspaceItem>()
for (const draft of globalDraftStore.listDrafts(workspace)) {
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
}
for (const entry of UserDraft.list({ workspace, itemKinds: [...SHARED_DRAFT_KINDS] })) {
const draft = sharedDraftEntryToWorkspaceItem(entry)
if (!draft) continue
@@ -252,14 +251,12 @@ export function deleteGlobalDraft(
if (isSharedWorkspaceItemType(type)) {
deleteSharedDraft(workspace, type, path, triggerKind)
}
globalDraftStore.deleteDraft(workspace, type, path, triggerKind)
}
export function clearGlobalDrafts(workspace: string): void {
for (const draft of UserDraft.list({ workspace, itemKinds: [...SHARED_DRAFT_KINDS] })) {
UserDraft.remove(draft.itemKind, draft.path, { workspace })
}
globalDraftStore.clearDrafts(workspace)
}
export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind {
@@ -0,0 +1,102 @@
import type {
AzureTriggerData,
CreateResource,
CreateVariable,
FlowValue,
GcpTriggerData,
NewHttpTrigger,
NewKafkaTrigger,
NewMqttTrigger,
NewNatsTrigger,
NewPostgresTrigger,
NewSchedule,
NewSqsTrigger,
NewWebsocketTrigger,
Policy,
ScriptLang
} from '$lib/gen/types.gen'
/**
* Flow draft value. Mirrors what the backend's create/update flow API expects
* -- the OpenFlow value, plus the inputs schema and (optional) groups.
*
* Schema and groups are split out from FlowValue intentionally so that
* deploy_workspace_item can preserve them through the draft -> workspace
* round-trip; an earlier version dropped them on every deploy.
*/
export type FlowDraftValue = {
value: FlowValue
schema?: Record<string, any> | null
groups?: NonNullable<FlowValue['groups']> | null
}
export const TRIGGER_KINDS = [
'http',
'websocket',
'kafka',
'nats',
'postgres',
'mqtt',
'sqs',
'gcp',
'azure'
] as const
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
export type TriggerRequestBody =
| NewHttpTrigger
| NewWebsocketTrigger
| NewKafkaTrigger
| NewNatsTrigger
| NewPostgresTrigger
| NewMqttTrigger
| NewSqsTrigger
| GcpTriggerData
| AzureTriggerData
export type WorkspaceItemType =
| 'script'
| 'flow'
| 'schedule'
| 'trigger'
| 'resource'
| 'variable'
| 'app'
export type AppDraftValue = {
summary?: string
files: Record<string, string>
runnables: Record<string, any>
data?: any
policy?: Policy
custom_path?: string
}
export type WorkspaceItem = {
type: WorkspaceItemType
path: string
summary?: string
language?: ScriptLang
triggerKind?: TriggerKind
value?:
| string
| FlowDraftValue
| NewSchedule
| TriggerRequestBody
| CreateResource
| CreateVariable
| AppDraftValue
isDraft: boolean
}
export function getWorkspaceItemKey(
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
if (type === 'trigger') {
return `trigger:${triggerKind ?? ''}:${path}`
}
return `${type}:${path}`
}