From 308666c019bef98e3ff02dfc828b63f7f71db193 Mon Sep 17 00:00:00 2001 From: centdix Date: Tue, 19 May 2026 16:00:47 +0200 Subject: [PATCH] feat: read local userdraft state in global ai --- .../copilot/chat/global/core.test.ts | 88 +++++++++- .../components/copilot/chat/global/core.ts | 164 ++++++++++++++---- .../copilot/chat/global/userDraftAdapter.ts | 164 ++++++++++++++---- 3 files changed, 343 insertions(+), 73 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index b5fd9e31be..655b8dd46c 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -26,6 +26,14 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ vi.mock('$lib/components/vscode', () => ({})) +vi.mock('svelte', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + onDestroy: vi.fn() + } +}) + vi.mock('$lib/gen', async () => { const actual = await vi.importActual('$lib/gen') @@ -93,6 +101,11 @@ vi.mock('$lib/gen', async () => { AppService: wrapService(actual.AppService, { existsApp: vi.fn(async () => false) }), + ResourceService: wrapService(actual.ResourceService, { + existsResource: vi.fn(async () => false), + listResource: vi.fn(async () => []), + queryResourceTypes: vi.fn(async () => []) + }), VariableService: wrapService(actual.VariableService, { existsVariable: vi.fn(async () => false), getVariable: vi.fn(async ({ path }: { path: string }) => ({ @@ -146,11 +159,40 @@ describe('global AI tools', () => { 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) + it('writes resource and variable drafts into the shared UserDraft store', async () => { + await callGlobalTool('write_variable', { + path: 'f/secrets/api_key', + value: 'secret', + is_secret: true, + description: 'API key' + }) + await callGlobalTool('write_resource', { + path: 'f/resources/api', + resource_type: 'http', + description: 'API resource', + value: { + token: '$var:f/secrets/api_key' + } + }) - expect(toolNames).not.toContain('write_resource') - expect(toolNames).not.toContain('write_variable') + expect( + UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/secrets/api_key', + value: 'secret', + is_secret: true, + description: 'API key' + }) + expect( + UserDraft.get('resource', 'f/resources/api', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/resources/api', + resource_type: 'http', + description: 'API resource', + value: { + token: '$var:f/secrets/api_key' + } + }) }) it('writes script drafts into the shared UserDraft store', async () => { @@ -182,10 +224,39 @@ describe('global AI tools', () => { summary: 'Hello script', language: 'bun', value: content, - isDraft: true + isDraft: false }) }) + it('reads a live UserDraft editor baseline as current context without marking it draft', async () => { + const content = 'export async function main() {\n\treturn "open editor"\n}' + const handle = UserDraft.use('script', 'f/scripts/open-editor', { workspace: WORKSPACE }) + handle.setDraftAndMeta( + { + path: 'f/scripts/open-editor', + summary: 'Open editor baseline', + language: 'bun', + content + }, + { remoteRev: 'v1' } + ) + + const raw = await callGlobalTool('read_workspace_item', { + type: 'script', + path: 'f/scripts/open-editor' + }) + + expect(JSON.parse(raw)).toMatchObject({ + type: 'script', + path: 'f/scripts/open-editor', + summary: 'Open editor baseline', + language: 'bun', + value: content, + isDraft: false + }) + expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/open-editor`)).toBeNull() + }) + it('overlays shared UserDraft entries in list_workspace_items', async () => { await callGlobalTool('write_script', { path: 'f/scripts/listed', @@ -203,7 +274,7 @@ describe('global AI tools', () => { type: 'script', path: 'f/scripts/listed', summary: 'Listed script', - isDraft: true + isDraft: false }) ]) }) @@ -242,7 +313,7 @@ describe('global AI tools', () => { schedule: '0 0 12 * * *', timezone: 'UTC' }), - isDraft: true + isDraft: false }) }) @@ -280,7 +351,7 @@ describe('global AI tools', () => { triggerKind: 'http', path: 'f/triggers/hook', summary: 'Hook trigger', - isDraft: true + isDraft: false }) ]) }) @@ -405,6 +476,7 @@ describe('global AI tools', () => { }) const item = JSON.parse(raw) + expect(item.isDraft).toBe(false) expect(item.value).toMatchObject({ modules: [ { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index d03dc5e491..0d1d79a9d9 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -61,8 +61,10 @@ import { } from '../shared' import type { ContextElement } from '../context' import { + resourceRequestSchema, scheduleRequestSchema, - triggerRequestSchemas + triggerRequestSchemas, + variableRequestSchema } from '../workspaceToolsZod.gen' import { getWorkspaceItemKey, @@ -77,8 +79,9 @@ import { import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' import { deleteGlobalDraft, + getGlobalCurrentItem, getGlobalDraft, - listGlobalDrafts, + listGlobalCurrentItems, triggerKindToUserDraftKind } from './userDraftAdapter' @@ -276,6 +279,10 @@ 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 @@ -467,7 +474,7 @@ 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 for supported item types. +You can inspect workspace scripts, flows, schedules, triggers, resources, variables, and apps, then create normal local UserDraft changes for supported item types. Important rules: - write_{script,flow,schedule,trigger} create or overwrite drafts. They do not save, deploy, or mutate workspace items. @@ -476,14 +483,14 @@ Important rules: - 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. - delete_workspace_item permanently removes a workspace item (and any matching draft). Irreversible. Requires user confirmation. Only call when the user has explicitly asked to delete. - Use list_workspace_items before broad reads. -- Use read_workspace_item before overwriting an existing item, unless the user already provided the complete current item. For triggers, pass trigger_kind. +- Use read_workspace_item before overwriting an existing item, unless the user already provided the complete current item. It returns the freshest frontend-visible value: local UserDraft state when present, otherwise backend state. For triggers, pass trigger_kind. - Variable values are NEVER returned by read_workspace_item or list_workspace_items — only metadata (path, description, is_secret). The model cannot read secret values, by design. - For resources that need secrets, write a Variable first (with is_secret: true), then in the resource value reference it as "$var:path/to/variable". When deploying both, deploy the variable before the resource. - Use search_resource_types before write_resource to discover the resource_type name and the JSON Schema its value must match. - Use get_instructions before writing a script, flow, resource, or app. For scripts, pass the target language; when modifying, use the language from the item you read. - Schedules, triggers, and variables do not need get_instructions — their tool schemas describe every field. - When a required decision is ambiguous, use askUserQuestion with two to six clear answer strings instead of guessing. -- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, read_workspace_item returns value as the compact flow object { modules, schema, preprocessor_module, failure_module, groups }; write_flow takes the same flow fields as top-level tool arguments plus path/summary. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions. +- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For AI read context, local UserDraft state is not always marked as isDraft because it may be a clean open-editor baseline rather than an unsaved change. For scripts, value is the source code string. For flows, read_workspace_item returns value as the compact flow object { modules, schema, preprocessor_module, failure_module, groups }; write_flow takes the same flow fields as top-level tool arguments plus path/summary. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions. - Apps (raw apps): use list_workspace_items with types: ['app'] to find them, read_workspace_item with type 'app' for a metadata summary (file paths + runnable list, no contents), then read_app_file to read individual files. Edit with write_app_file / patch_app_file / delete_app_file for frontend files and write_app_runnable / delete_app_runnable for backend runnables. Frontend file paths start with "/" (e.g. /index.tsx). Backend inline runnables are addressed as "backend//main.{ts|py}". /wmill.d.ts is generated and cannot be written. - To create a new raw app, use init_app. Before calling it, confirm framework (react19 / react18 / svelte5 / vue), path, and summary with the user — do not silently default to react19, even though it is the recommended choice. - Apps cannot be deployed from chat. The app editor bundles JS/CSS before save; tell the user to open the app editor to deploy app drafts. @@ -818,9 +825,14 @@ function getInlineRunnableContent( } async function loadAppDraftValue(path: string, workspace: string): Promise { - const draft = getGlobalDraft(workspace, 'app', path) - if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { - return draft.value as AppDraftValue + const currentItem = getGlobalCurrentItem(workspace, 'app', path) + if ( + currentItem && + currentItem.value && + typeof currentItem.value === 'object' && + 'files' in currentItem.value + ) { + return currentItem.value as AppDraftValue } const app = await AppService.getAppByPath({ workspace, path }) @@ -1138,7 +1150,7 @@ ${getRawAppPrompt()}` function getResourceInstructions(): string { return `# Resource & variable reference -- Global mode can inspect resources and variables, but cannot create resource or variable drafts until their editor-native UserDraft contracts are complete. +- Global mode can create local resource and variable drafts. Drafts are not deployed until the user explicitly asks to deploy them. - 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\`. - Use \`search_resource_types\` to discover valid \`resource_type\` names and their JSON Schemas. Match the resource value to that schema. @@ -1237,7 +1249,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( listWorkspaceItemsSchema, 'list_workspace_items', - 'List workspace items (scripts, flows, schedules, triggers, resources, variables, apps) and AI drafts. Returns metadata only (no value). Defaults to scripts and flows.' + 'List workspace items (scripts, flows, schedules, triggers, resources, variables, apps) and local UserDraft entries. Returns metadata only (no value). Defaults to scripts and flows.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = listWorkspaceItemsSchema.parse(args) @@ -1256,12 +1268,15 @@ export const globalTools: Tool<{}>[] = [ byKey.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item) } - for (const draft of listGlobalDrafts(workspace)) { - if (!types.includes(draft.type)) continue - byKey.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), { - ...draft, - value: undefined - }) + for (const currentItem of listGlobalCurrentItems(workspace)) { + if (!types.includes(currentItem.type)) continue + byKey.set( + getWorkspaceItemKey(currentItem.type, currentItem.path, currentItem.triggerKind), + { + ...currentItem, + value: undefined + } + ) } const results = Array.from(byKey.values()) @@ -1278,7 +1293,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( readWorkspaceItemSchema, 'read_workspace_item', - 'Read one workspace item or AI draft by type and path. Returns the full workspace item including value.' + 'Read the freshest frontend-visible value for one workspace item by type and path. Uses local UserDraft state when present, otherwise reads the backend item.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = readWorkspaceItemSchema.parse(args) @@ -1287,12 +1302,17 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: message, error: message }) return JSON.stringify({ success: false, error: message }) } - const draft = getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) - if (draft) { + const currentItem = getGlobalCurrentItem( + workspace, + parsed.type, + parsed.path, + parsed.trigger_kind + ) + if (currentItem) { toolCallbacks.setToolStatus(toolId, { - content: `Read AI draft ${parsed.type} "${parsed.path}"` + content: `Read local current ${parsed.type} "${parsed.path}"` }) - return JSON.stringify(serializeWorkspaceItemForRead(draft), null, 2) + return JSON.stringify(serializeWorkspaceItemForRead(currentItem), null, 2) } toolCallbacks.setToolStatus(toolId, { @@ -1441,8 +1461,36 @@ export const globalTools: Tool<{}>[] = [ return deleteWorkspaceItem(parsed, ctx) } }, - // TODO: Re-enable write_resource/write_variable tools after resource and - // variable use self-contained editor-native UserDraft values. + { + 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 writeResourceDraft(parsed, 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 writeVariableDraft(parsed, ctx) + } + }, { def: createToolDef( searchResourceTypesSchema, @@ -1616,6 +1664,8 @@ function getRequiredGlobalDraft( function finishDraftWrite(item: WorkspaceItem, exists: boolean, ctx: WriteDraftCtx): string { const verb = exists ? 'Updated' : 'Created' + const serializedItem = + item.type === 'variable' || item.type === 'flow' ? serializeWorkspaceItemForRead(item) : item ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: `${verb} AI draft ${item.type} "${item.path}"`, result: `Draft ${verb.toLowerCase()}` @@ -1624,7 +1674,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: serializedItem }, null, 2 @@ -1790,6 +1840,48 @@ async function writeTriggerDraft( ) } +async function writeResourceDraft( + args: z.infer, + ctx: WriteDraftCtx +): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'resource', args.path) + + const draft = structuredClone(args) + const existingDraft = UserDraft.get('resource', args.path, { workspace }) + const backendExists = existingDraft + ? false + : await ResourceService.existsResource({ workspace, path: args.path }) + + UserDraft.save('resource', args.path, draft, { workspace }) + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'resource', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeVariableDraft( + args: z.infer, + ctx: WriteDraftCtx +): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'variable', args.path) + + const draft = structuredClone(args) + const existingDraft = UserDraft.get('variable', args.path, { workspace }) + const backendExists = existingDraft + ? false + : await VariableService.existsVariable({ workspace, path: args.path }) + + UserDraft.save('variable', args.path, draft, { workspace }) + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'variable', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + function saveAppDraft(workspace: string, path: string, value: AppDraftValue): WorkspaceItem { UserDraft.save('raw_app', path, normalizeAppDraftValue(value), { workspace }) return getRequiredGlobalDraft(workspace, 'app', path) @@ -1799,12 +1891,16 @@ async function loadScriptForEdit( path: string, workspace: string ): Promise<{ content: string; language: ScriptLang; summary?: string }> { - const draft = getGlobalDraft(workspace, 'script', path) - if (draft) { - if (typeof draft.value !== 'string' || !draft.language) { - throw new Error(`Draft script "${path}" is missing content or language.`) + const currentItem = getGlobalCurrentItem(workspace, 'script', path) + if (currentItem) { + if (typeof currentItem.value !== 'string' || !currentItem.language) { + throw new Error(`Local script "${path}" is missing content or language.`) + } + return { + content: currentItem.value, + language: currentItem.language, + summary: currentItem.summary } - return { content: draft.value, language: draft.language, summary: draft.summary } } const script = await ScriptService.getScriptByPath({ workspace, path }) return { content: script.content, language: script.language, summary: script.summary } @@ -1834,12 +1930,12 @@ async function loadFlowDraftValue( path: string, workspace: string ): Promise<{ flow: FlowDraftValue; summary?: string }> { - const draft = getGlobalDraft(workspace, 'flow', path) - if (draft) { - if (draft.value === undefined || typeof draft.value === 'string') { - throw new Error(`Draft flow "${path}" has no value.`) + const currentItem = getGlobalCurrentItem(workspace, 'flow', path) + if (currentItem) { + if (currentItem.value === undefined || typeof currentItem.value === 'string') { + throw new Error(`Local flow "${path}" has no value.`) } - return { flow: draft.value as FlowDraftValue, summary: draft.summary } + return { flow: currentItem.value as FlowDraftValue, summary: currentItem.summary } } const flow = await FlowService.getFlowByPath({ workspace, path }) return { diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 4e8be9c5e5..8c6d9c9074 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -1,4 +1,10 @@ -import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen' +import type { + CreateResource, + CreateVariable, + Flow, + NewSchedule, + NewScript +} from '$lib/gen/types.gen' import { UserDraft, type UserDraftItemKind, type UserDraftListEntry } from '$lib/userDraft.svelte' import { getWorkspaceItemKey, @@ -9,7 +15,14 @@ import { type WorkspaceItemType } from './workspaceItems' -type SharedWorkspaceItemType = 'script' | 'flow' | 'app' | 'schedule' | 'trigger' +type SharedWorkspaceItemType = + | 'script' + | 'flow' + | 'app' + | 'schedule' + | 'trigger' + | 'resource' + | 'variable' const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = { http: 'trigger_http', @@ -48,15 +61,12 @@ const SHARED_DRAFT_KINDS = [ 'trigger_mqtt', 'trigger_sqs', 'trigger_gcp', - 'trigger_azure' + 'trigger_azure', + 'resource', + 'variable' ] 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(value: T): T { return structuredClone(value) as T } @@ -67,7 +77,9 @@ function isSharedWorkspaceItemType(type: WorkspaceItemType): type is SharedWorks type === 'flow' || type === 'app' || type === 'schedule' || - type === 'trigger' + type === 'trigger' || + type === 'resource' || + type === 'variable' ) } @@ -85,6 +97,9 @@ function sharedDraftKind( return 'trigger_schedule' case 'trigger': return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined + case 'resource': + case 'variable': + return type } } @@ -101,18 +116,18 @@ function getItemSummary(value: unknown): string | undefined { return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined } -function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem { +function scriptDraftToWorkspaceItem(path: string, draft: NewScript, isDraft = true): WorkspaceItem { return { type: 'script', path, summary: draft.summary, language: draft.language, value: draft.content, - isDraft: true + isDraft } } -function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem { +function flowDraftToWorkspaceItem(path: string, draft: Flow, isDraft = true): WorkspaceItem { return { type: 'flow', path, @@ -122,35 +137,44 @@ function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem { schema: draft.schema ?? null, groups: draft.value.groups ?? null }, - isDraft: true + isDraft } } -function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceItem { +function appDraftToWorkspaceItem( + path: string, + draft: AppDraftValue, + isDraft = true +): WorkspaceItem { const value = normalizeAppDraftValue(draft) return { type: 'app', path, summary: value.summary, value, - isDraft: true + isDraft } } -function scheduleDraftToWorkspaceItem(path: string, draft: NewSchedule): WorkspaceItem { +function scheduleDraftToWorkspaceItem( + path: string, + draft: NewSchedule, + isDraft = true +): WorkspaceItem { return { type: 'schedule', path, summary: draft.summary ?? undefined, value: clone(draft), - isDraft: true + isDraft } } function triggerDraftToWorkspaceItem( kind: TriggerKind, path: string, - draft: TriggerRequestBody + draft: TriggerRequestBody, + isDraft = true ): WorkspaceItem { return { type: 'trigger', @@ -158,24 +182,64 @@ function triggerDraftToWorkspaceItem( path, summary: getItemSummary(draft), value: clone(draft), - isDraft: true + isDraft } } -function sharedDraftEntryToWorkspaceItem(entry: UserDraftListEntry): WorkspaceItem | undefined { +function resourceDraftToWorkspaceItem( + path: string, + draft: CreateResource, + isDraft = true +): WorkspaceItem { + return { + type: 'resource', + path, + summary: draft.description ?? undefined, + value: clone(draft), + isDraft + } +} + +function variableDraftToWorkspaceItem( + path: string, + draft: CreateVariable, + isDraft = true +): WorkspaceItem { + return { + type: 'variable', + path, + summary: draft.description ?? undefined, + value: clone(draft), + isDraft + } +} + +function sharedDraftEntryToWorkspaceItem( + entry: UserDraftListEntry, + isDraft = true +): WorkspaceItem | undefined { switch (entry.itemKind) { case 'script': - return scriptDraftToWorkspaceItem(entry.path, entry.value as NewScript) + return scriptDraftToWorkspaceItem(entry.path, entry.value as NewScript, isDraft) case 'flow': - return flowDraftToWorkspaceItem(entry.path, entry.value as Flow) + return flowDraftToWorkspaceItem(entry.path, entry.value as Flow, isDraft) case 'raw_app': - return appDraftToWorkspaceItem(entry.path, entry.value as AppDraftValue) + return appDraftToWorkspaceItem(entry.path, entry.value as AppDraftValue, isDraft) case 'trigger_schedule': - return scheduleDraftToWorkspaceItem(entry.path, entry.value as NewSchedule) + return scheduleDraftToWorkspaceItem(entry.path, entry.value as NewSchedule, isDraft) + case 'resource': + return resourceDraftToWorkspaceItem(entry.path, entry.value as CreateResource, isDraft) + case 'variable': + return variableDraftToWorkspaceItem(entry.path, entry.value as CreateVariable, isDraft) default: const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind] return triggerKind - ? triggerDraftToWorkspaceItem(triggerKind, entry.path, entry.value as TriggerRequestBody) + ? triggerDraftToWorkspaceItem( + triggerKind, + entry.path, + entry.value as TriggerRequestBody, + isDraft + ) : undefined } } @@ -184,7 +248,8 @@ function getSharedDraft( workspace: string, type: SharedWorkspaceItemType, path: string, - triggerKind?: TriggerKind + triggerKind?: TriggerKind, + isDraft = true ): WorkspaceItem | undefined { const itemKind = sharedDraftKind(type, triggerKind) if (!itemKind) return undefined @@ -193,17 +258,21 @@ function getSharedDraft( switch (type) { case 'script': - return scriptDraftToWorkspaceItem(path, draft as NewScript) + return scriptDraftToWorkspaceItem(path, draft as NewScript, isDraft) case 'flow': - return flowDraftToWorkspaceItem(path, draft as Flow) + return flowDraftToWorkspaceItem(path, draft as Flow, isDraft) case 'app': - return appDraftToWorkspaceItem(path, draft as AppDraftValue) + return appDraftToWorkspaceItem(path, draft as AppDraftValue, isDraft) case 'schedule': - return scheduleDraftToWorkspaceItem(path, draft as NewSchedule) + return scheduleDraftToWorkspaceItem(path, draft as NewSchedule, isDraft) case 'trigger': return triggerKind - ? triggerDraftToWorkspaceItem(triggerKind, path, draft as TriggerRequestBody) + ? triggerDraftToWorkspaceItem(triggerKind, path, draft as TriggerRequestBody, isDraft) : undefined + case 'resource': + return resourceDraftToWorkspaceItem(path, draft as CreateResource, isDraft) + case 'variable': + return variableDraftToWorkspaceItem(path, draft as CreateVariable, isDraft) } } @@ -230,6 +299,23 @@ export function getGlobalDraft( return undefined } +/** + * Returns the freshest frontend-visible value for AI context. This may be a + * real local draft, an AI-written UserDraft, or a clean live editor baseline. + * Therefore the returned item is intentionally not marked as `isDraft`. + */ +export function getGlobalCurrentItem( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): WorkspaceItem | undefined { + if (isSharedWorkspaceItemType(type)) { + return getSharedDraft(workspace, type, path, triggerKind, false) + } + return undefined +} + export function listGlobalDrafts(workspace: string): WorkspaceItem[] { const drafts = new Map() @@ -242,6 +328,22 @@ export function listGlobalDrafts(workspace: string): WorkspaceItem[] { return Array.from(drafts.values()) } +/** + * Lists local/current entries for AI context. Entries are not marked as + * `isDraft` because UserDraft.list can include clean live editor baselines. + */ +export function listGlobalCurrentItems(workspace: string): WorkspaceItem[] { + const items = new Map() + + for (const entry of UserDraft.list({ workspace, itemKinds: [...SHARED_DRAFT_KINDS] })) { + const item = sharedDraftEntryToWorkspaceItem(entry, false) + if (!item) continue + items.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item) + } + + return Array.from(items.values()) +} + export function deleteGlobalDraft( workspace: string, type: WorkspaceItemType,