From 110963748e4562e52ab098ac8dc6a5dbbb12511b Mon Sep 17 00:00:00 2001 From: centdix Date: Wed, 13 May 2026 14:41:35 +0200 Subject: [PATCH] refactor: write global drafts through userdraft --- .../components/copilot/chat/global/core.ts | 328 +++++++++++++----- .../copilot/chat/global/userDraftAdapter.ts | 203 +---------- 2 files changed, 242 insertions(+), 289 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 847b624fc5..23b5afe6a1 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -23,10 +23,14 @@ import type { ListableApp, ListableResource, ListableVariable, + NewSchedule, + NewScript, Schedule, Script, ScriptLang } from '$lib/gen/types.gen' +import { UserDraft } from '$lib/userDraft.svelte' +import { emptySchema } from '$lib/utils' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { FRAMEWORK_TEMPLATES, @@ -64,9 +68,11 @@ import { } from '../workspaceToolsZod.gen' import { getWorkspaceItemKey, + globalDraftStore, TRIGGER_KINDS, type AppDraftValue, type FlowDraftValue, + type TriggerRequestBody, type TriggerKind, type WorkspaceItem, type WorkspaceItemType @@ -76,8 +82,7 @@ import { deleteGlobalDraft, getGlobalDraft, listGlobalDrafts, - saveGlobalAppDraft, - setGlobalDraft + triggerKindToUserDraftKind } from './userDraftAdapter' const ITEM_TYPES = [ @@ -837,10 +842,6 @@ async function loadAppDraftValue(path: string, workspace: string): Promise[] = [ showFade: true, fn: async (ctx) => { const parsed = writeScriptSchema.parse(ctx.args) - return writeDraft( - { - type: 'script', - path: parsed.path, - summary: parsed.summary, - language: parsed.language, - value: parsed.content, - isDraft: true - }, - ctx - ) + return writeScriptDraft(parsed, ctx) } }, { @@ -1382,13 +1373,11 @@ export const globalTools: Tool<{}>[] = [ failure_module: parseOptionalJsonArg(parsed.failure_module, 'failure_module'), groups: parseOptionalJsonArg(parsed.groups, 'groups') }) - return writeDraft( + return writeFlowDraft( { - type: 'flow', path: parsed.path, summary: parsed.summary, - value: editableFlowToDraftValue(editable), - isDraft: true + flow: editableFlowToDraftValue(editable) }, ctx ) @@ -1406,16 +1395,7 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeScheduleSchema.parse(ctx.args) - return writeDraft( - { - type: 'schedule', - path: parsed.path, - summary: parsed.summary ?? undefined, - value: parsed, - isDraft: true - }, - ctx - ) + return writeScheduleDraft(parsed as NewSchedule, ctx) } }, { @@ -1430,16 +1410,9 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeTriggerSchema.parse(ctx.args) - const config = parsed.config as { path: string; summary?: string | null } - return writeDraft( - { - type: 'trigger', - triggerKind: parsed.kind, - path: config.path, - summary: config.summary ?? undefined, - value: parsed.config, - isDraft: true - }, + return writeTriggerDraft( + parsed.kind, + parsed.config as TriggerRequestBody & { path: string }, ctx ) } @@ -1515,7 +1488,7 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeResourceSchema.parse(ctx.args) - return writeDraft( + return writeFallbackDraft( { type: 'resource', path: parsed.path, @@ -1539,7 +1512,7 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeVariableSchema.parse(ctx.args) - return writeDraft( + return writeFallbackDraft( { type: 'variable', path: parsed.path, @@ -1703,6 +1676,220 @@ type WriteDraftCtx = { toolCallbacks: ToolCallbacks } +function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void { + ctx.toolCallbacks.setToolStatus(ctx.toolId, { + content: `Writing draft ${type} "${path}"...` + }) +} + +function getRequiredGlobalDraft( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): WorkspaceItem { + const draft = getGlobalDraft(workspace, type, path, triggerKind) + if (!draft) { + throw new Error(`Draft ${type} "${path}" was not saved.`) + } + return draft +} + +function finishDraftWrite(item: WorkspaceItem, exists: boolean, ctx: WriteDraftCtx): string { + const verb = exists ? 'Updated' : 'Created' + ctx.toolCallbacks.setToolStatus(ctx.toolId, { + content: `${verb} AI draft ${item.type} "${item.path}"`, + result: `Draft ${verb.toLowerCase()}` + }) + return JSON.stringify( + { + success: true, + message: `${verb} AI draft ${item.type} "${item.path}". The workspace was not saved or deployed.`, + item: item.type === 'variable' ? serializeWorkspaceItemForRead(item) : item + }, + null, + 2 + ) +} + +function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue { + return { + ...value, + files: { ...(value.files ?? {}) }, + runnables: { ...(value.runnables ?? {}) }, + data: value.data ?? { tables: [], datatable: undefined, schema: undefined } + } +} + +async function writeScriptDraft( + args: { path: string; summary?: string; language: ScriptLang; content: string }, + ctx: WriteDraftCtx +): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'script', args.path) + + const existingDraft = UserDraft.get('script', args.path, { workspace }) + const backendExists = existingDraft + ? false + : await ScriptService.existsScriptByPath({ workspace, path: args.path }) + + let draft: NewScript + if (existingDraft) { + draft = { + ...structuredClone(existingDraft), + path: args.path, + summary: args.summary ?? existingDraft.summary, + content: args.content, + language: args.language + } + } else if (backendExists) { + const existing = await ScriptService.getScriptByPath({ workspace, path: args.path }) + draft = { + ...structuredClone(existing), + parent_hash: existing.hash, + path: args.path, + summary: args.summary ?? existing.summary, + content: args.content, + language: args.language + } + } else { + draft = { + path: args.path, + summary: args.summary ?? '', + description: '', + content: args.content, + schema: emptySchema(), + is_template: false, + language: args.language, + kind: 'script' + } + } + + UserDraft.save('script', args.path, draft, { workspace }) + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'script', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeFlowDraft( + args: { path: string; summary?: string; flow: FlowDraftValue }, + ctx: WriteDraftCtx +): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'flow', args.path) + + const draftValue = args.flow + const value = structuredClone(draftValue.value) + if (draftValue.groups !== undefined && draftValue.groups !== null) { + value.groups = structuredClone(draftValue.groups) + } + + const existingDraft = UserDraft.get('flow', args.path, { workspace }) + const backendExists = existingDraft + ? false + : await FlowService.existsFlowByPath({ workspace, path: args.path }) + + let draft: Flow + if (existingDraft) { + draft = { + ...structuredClone(existingDraft), + path: args.path, + summary: args.summary ?? existingDraft.summary, + value, + schema: draftValue.schema ?? existingDraft.schema + } + } else if (backendExists) { + const existing = await FlowService.getFlowByPath({ workspace, path: args.path }) + draft = { + ...structuredClone(existing), + path: args.path, + summary: args.summary ?? existing.summary, + value, + schema: draftValue.schema ?? existing.schema + } + } else { + draft = { + path: args.path, + summary: args.summary ?? '', + value, + schema: draftValue.schema ?? emptySchema(), + edited_by: '', + edited_at: '', + archived: false, + extra_perms: {} + } + } + + UserDraft.save('flow', args.path, draft, { workspace }) + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'flow', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'schedule', args.path) + + const draft = structuredClone(args) + const existingDraft = UserDraft.get('trigger_schedule', args.path, { workspace }) + const backendExists = existingDraft + ? false + : await ScheduleService.existsSchedule({ workspace, path: args.path }) + + UserDraft.save('trigger_schedule', args.path, draft, { workspace }) + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'schedule', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeTriggerDraft( + kind: TriggerKind, + args: TriggerRequestBody & { path: string }, + ctx: WriteDraftCtx +): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'trigger', args.path) + + const itemKind = triggerKindToUserDraftKind(kind) + const draft = structuredClone(args) + const existingDraft = UserDraft.get(itemKind, args.path, { workspace }) + const backendExists = existingDraft + ? false + : await triggerServices[kind].exists({ workspace, path: args.path }) + + UserDraft.save(itemKind, args.path, draft, { workspace }) + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'trigger', args.path, kind), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeFallbackDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise { + 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) +} + async function loadScriptForEdit( path: string, workspace: string @@ -1727,14 +1914,12 @@ async function editScript( const base = await loadScriptForEdit(path, ctx.workspace) const updated = findAndReplace(base.content, oldString, newString, replaceAll, 'script source') - return writeDraft( + return writeScriptDraft( { - type: 'script', path, summary: base.summary, language: base.language, - value: updated, - isDraft: true + content: updated }, ctx ) @@ -1791,18 +1976,16 @@ async function patchFlowJson( const patchedEditable = validateEditableFlowJson(parsedValue) const newFlowValue = applyEditableFlowJsonToFlow(base.flow.value, patchedEditable, session) - return writeDraft( + return writeFlowDraft( { - type: 'flow', path, summary: base.summary, - value: { + flow: { ...base.flow, value: newFlowValue, schema: patchedEditable.schema, groups: patchedEditable.groups - }, - isDraft: true + } }, ctx ) @@ -1849,13 +2032,11 @@ async function setFlowModuleCode( } session.set(args.module_id, args.code) const newFlowValue = applyEditableFlowJsonToFlow(base.flow.value, editable, session) - return writeDraft( + return writeFlowDraft( { - type: 'flow', path: args.path, summary: base.summary, - value: { ...base.flow, value: newFlowValue }, - isDraft: true + flow: { ...base.flow, value: newFlowValue } }, ctx ) @@ -2422,41 +2603,6 @@ async function deleteWorkspaceItem( ) } -async function writeDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise { - const { workspace, toolId, toolCallbacks } = ctx - toolCallbacks.setToolStatus(toolId, { - content: `Writing draft ${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 exists = existingDraft || backendExists - - const stored = await setGlobalDraft(workspace, item, { loadExisting: backendExists }) - const serializedItem = - stored.type === 'variable' || stored.type === 'flow' - ? serializeWorkspaceItemForRead(stored) - : stored - - const verb = exists ? 'Updated' : 'Created' - toolCallbacks.setToolStatus(toolId, { - content: `${verb} AI draft ${item.type} "${item.path}"`, - result: `Draft ${verb.toLowerCase()}` - }) - return JSON.stringify( - { - success: true, - message: `${verb} AI draft ${item.type} "${item.path}". The workspace was not saved or deployed.`, - item: serializedItem - }, - null, - 2 - ) -} - export function prepareGlobalSystemMessage( customPrompt?: string ): ChatCompletionSystemMessageParam { diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 4ad6d61528..8b2bc6fcbb 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -1,12 +1,9 @@ -import { FlowService, ScriptService } from '$lib/gen' -import type { Flow, NewSchedule, NewScript, Script, ScriptLang } from '$lib/gen/types.gen' +import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen' import { UserDraft, type UserDraftItemKind, type UserDraftListEntry } from '$lib/userDraft.svelte' -import { emptySchema } from '$lib/utils' import { getWorkspaceItemKey, globalDraftStore, type AppDraftValue, - type FlowDraftValue, type TriggerRequestBody, type TriggerKind, type WorkspaceItem, @@ -54,7 +51,6 @@ const SHARED_DRAFT_KINDS = [ 'trigger_gcp', 'trigger_azure' ] as const satisfies UserDraftItemKind[] -const DEFAULT_SCRIPT_LANGUAGE: ScriptLang = 'bun' const DEFAULT_APP_DATA = { tables: [], datatable: undefined, schema: undefined } function clone(value: T): T { @@ -101,14 +97,6 @@ function getItemSummary(value: unknown): string | undefined { return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined } -function applyItemSummary(value: T, summary: string | undefined): T { - const draft = value as T & { summary?: string | null } - if (draft.summary === undefined && summary !== undefined) { - draft.summary = summary - } - return value -} - function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem { return { type: 'script', @@ -188,123 +176,6 @@ function sharedDraftEntryToWorkspaceItem(entry: UserDraftListEntry): WorkspaceIt } } -function scriptItemToUserDraft(item: WorkspaceItem, existing?: Script): NewScript { - if (typeof item.value !== 'string') { - throw new Error(`Draft script "${item.path}" is missing source content.`) - } - - if (existing) { - return { - ...clone(existing), - parent_hash: existing.hash, - path: item.path, - summary: item.summary ?? existing.summary, - content: item.value, - language: item.language ?? existing.language - } - } - - return { - path: item.path, - summary: item.summary ?? '', - description: '', - content: item.value, - schema: emptySchema(), - is_template: false, - language: item.language ?? DEFAULT_SCRIPT_LANGUAGE, - kind: 'script' - } -} - -function flowItemToUserDraft(item: WorkspaceItem, existing?: Flow): Flow { - const draftValue = item.value as FlowDraftValue | undefined - if (!draftValue?.value) { - throw new Error(`Draft flow "${item.path}" is missing value.`) - } - - const value = clone(draftValue.value) - if (draftValue.groups !== undefined && draftValue.groups !== null) { - value.groups = clone(draftValue.groups) - } - - if (existing) { - return { - ...clone(existing), - path: item.path, - summary: item.summary ?? existing.summary, - value, - schema: draftValue.schema ?? existing.schema - } - } - - return { - path: item.path, - summary: item.summary ?? '', - value, - schema: draftValue.schema ?? emptySchema(), - edited_by: '', - edited_at: '', - archived: false, - extra_perms: {} - } -} - -function appItemToUserDraft(item: WorkspaceItem): AppDraftValue { - const value = item.value as AppDraftValue | undefined - if (!value?.files || !value?.runnables) { - throw new Error(`Draft app "${item.path}" is missing files or runnables.`) - } - return normalizeAppDraftValue({ - ...clone(value), - summary: value.summary ?? item.summary - }) -} - -function scheduleItemToUserDraft(item: WorkspaceItem): NewSchedule { - const value = item.value as NewSchedule | undefined - if (!value) { - throw new Error(`Draft schedule "${item.path}" is missing value.`) - } - const draft = { - ...clone(value), - path: item.path - } - return applyItemSummary(draft, item.summary) -} - -function triggerItemToUserDraft(item: WorkspaceItem): TriggerRequestBody { - const value = item.value as TriggerRequestBody | undefined - if (!item.triggerKind) { - throw new Error(`Draft trigger "${item.path}" is missing trigger kind.`) - } - if (!value) { - throw new Error(`Draft trigger "${item.path}" is missing value.`) - } - const draft = { - ...clone(value), - path: item.path - } - return applyItemSummary(draft, item.summary) -} - -async function loadExistingScript( - workspace: string, - path: string, - loadExisting: boolean | undefined -): Promise