From c8418ece270de58aa644d98bc76fc10e63019d91 Mon Sep 17 00:00:00 2001 From: centdix Date: Wed, 13 May 2026 14:28:31 +0200 Subject: [PATCH] feat: share trigger drafts with global ai --- .../copilot/chat/global/core.test.ts | 117 +++++++++++ .../copilot/chat/global/userDraftAdapter.ts | 183 ++++++++++++++++-- 2 files changed, 287 insertions(+), 13 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 7e4ee85ca1..79f0256afa 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -50,6 +50,46 @@ vi.mock('$lib/gen', async () => { existsFlowByPath: vi.fn(async () => false), listFlows: vi.fn(async () => []) }), + ScheduleService: wrapService(actual.ScheduleService, { + existsSchedule: vi.fn(async () => false), + listSchedules: vi.fn(async () => []) + }), + HttpTriggerService: wrapService(actual.HttpTriggerService, { + existsHttpTrigger: vi.fn(async () => false), + listHttpTriggers: vi.fn(async () => []) + }), + WebsocketTriggerService: wrapService(actual.WebsocketTriggerService, { + existsWebsocketTrigger: vi.fn(async () => false), + listWebsocketTriggers: vi.fn(async () => []) + }), + KafkaTriggerService: wrapService(actual.KafkaTriggerService, { + existsKafkaTrigger: vi.fn(async () => false), + listKafkaTriggers: vi.fn(async () => []) + }), + NatsTriggerService: wrapService(actual.NatsTriggerService, { + existsNatsTrigger: vi.fn(async () => false), + listNatsTriggers: vi.fn(async () => []) + }), + PostgresTriggerService: wrapService(actual.PostgresTriggerService, { + existsPostgresTrigger: vi.fn(async () => false), + listPostgresTriggers: vi.fn(async () => []) + }), + MqttTriggerService: wrapService(actual.MqttTriggerService, { + existsMqttTrigger: vi.fn(async () => false), + listMqttTriggers: vi.fn(async () => []) + }), + SqsTriggerService: wrapService(actual.SqsTriggerService, { + existsSqsTrigger: vi.fn(async () => false), + listSqsTriggers: vi.fn(async () => []) + }), + GcpTriggerService: wrapService(actual.GcpTriggerService, { + existsGcpTrigger: vi.fn(async () => false), + listGcpTriggers: vi.fn(async () => []) + }), + AzureTriggerService: wrapService(actual.AzureTriggerService, { + existsAzureTrigger: vi.fn(async () => false), + listAzureTriggers: vi.fn(async () => []) + }), AppService: wrapService(actual.AppService, { existsApp: vi.fn(async () => false) }), @@ -156,6 +196,83 @@ describe('global AI tools', () => { ]) }) + it('writes schedule drafts into the shared UserDraft store', async () => { + await callGlobalTool('write_schedule', { + path: 'f/schedules/daily', + summary: 'Daily schedule', + schedule: '0 0 12 * * *', + timezone: 'UTC', + script_path: 'f/scripts/hello', + is_flow: false, + args: { name: 'Ada' }, + enabled: true + }) + + expect( + UserDraft.get('trigger_schedule', 'f/schedules/daily', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/schedules/daily', + summary: 'Daily schedule', + script_path: 'f/scripts/hello', + is_flow: false + }) + + const raw = await callGlobalTool('read_workspace_item', { + type: 'schedule', + path: 'f/schedules/daily' + }) + + expect(JSON.parse(raw)).toMatchObject({ + type: 'schedule', + path: 'f/schedules/daily', + summary: 'Daily schedule', + value: expect.objectContaining({ + schedule: '0 0 12 * * *', + timezone: 'UTC' + }), + isDraft: true + }) + }) + + it('writes trigger drafts into the shared trigger UserDraft kind', async () => { + await callGlobalTool('write_trigger', { + kind: 'http', + config: { + path: 'f/triggers/hook', + summary: 'Hook trigger', + script_path: 'f/scripts/hello', + route_path: 'api/hook', + is_flow: false, + http_method: 'post', + authentication_method: 'none', + is_static_website: false + } + }) + + expect( + UserDraft.get('trigger_http', 'f/triggers/hook', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/triggers/hook', + summary: 'Hook trigger', + route_path: 'api/hook', + is_flow: false + }) + + const raw = await callGlobalTool('list_workspace_items', { + types: ['trigger'] + }) + + expect(JSON.parse(raw)).toEqual([ + expect.objectContaining({ + type: 'trigger', + triggerKind: 'http', + path: 'f/triggers/hook', + summary: 'Hook trigger', + isDraft: true + }) + ]) + }) + it('redacts variable draft values when reading workspace items', async () => { await callGlobalTool('write_variable', { path: 'f/secrets/api_key', diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index f41fc96132..4ad6d61528 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -1,5 +1,5 @@ import { FlowService, ScriptService } from '$lib/gen' -import type { Flow, NewScript, Script, ScriptLang } from '$lib/gen/types.gen' +import type { Flow, NewSchedule, NewScript, Script, ScriptLang } from '$lib/gen/types.gen' import { UserDraft, type UserDraftItemKind, type UserDraftListEntry } from '$lib/userDraft.svelte' import { emptySchema } from '$lib/utils' import { @@ -7,14 +7,53 @@ import { globalDraftStore, type AppDraftValue, type FlowDraftValue, + type TriggerRequestBody, type TriggerKind, type WorkspaceItem, type WorkspaceItemType } from './draftStore.svelte' -type SharedWorkspaceItemType = 'script' | 'flow' | 'app' +type SharedWorkspaceItemType = 'script' | 'flow' | 'app' | 'schedule' | 'trigger' -const SHARED_DRAFT_KINDS = ['script', 'flow', 'raw_app'] as const satisfies UserDraftItemKind[] +const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = { + http: 'trigger_http', + websocket: 'trigger_websocket', + kafka: 'trigger_kafka', + nats: 'trigger_nats', + postgres: 'trigger_postgres', + mqtt: 'trigger_mqtt', + sqs: 'trigger_sqs', + gcp: 'trigger_gcp', + azure: 'trigger_azure' +} as const satisfies Record + +const TRIGGER_KIND_BY_DRAFT_KIND = { + trigger_http: 'http', + trigger_websocket: 'websocket', + trigger_kafka: 'kafka', + trigger_nats: 'nats', + trigger_postgres: 'postgres', + trigger_mqtt: 'mqtt', + trigger_sqs: 'sqs', + trigger_gcp: 'gcp', + trigger_azure: 'azure' +} as const satisfies Partial> + +const SHARED_DRAFT_KINDS = [ + 'script', + 'flow', + 'raw_app', + 'trigger_schedule', + 'trigger_http', + 'trigger_websocket', + 'trigger_kafka', + 'trigger_nats', + 'trigger_postgres', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure' +] as const satisfies UserDraftItemKind[] const DEFAULT_SCRIPT_LANGUAGE: ScriptLang = 'bun' const DEFAULT_APP_DATA = { tables: [], datatable: undefined, schema: undefined } @@ -23,11 +62,30 @@ function clone(value: T): T { } function isSharedWorkspaceItemType(type: WorkspaceItemType): type is SharedWorkspaceItemType { - return type === 'script' || type === 'flow' || type === 'app' + return ( + type === 'script' || + type === 'flow' || + type === 'app' || + type === 'schedule' || + type === 'trigger' + ) } -function sharedDraftKind(type: SharedWorkspaceItemType): UserDraftItemKind { - return type === 'app' ? 'raw_app' : type +function sharedDraftKind( + type: SharedWorkspaceItemType, + triggerKind?: TriggerKind +): UserDraftItemKind | undefined { + switch (type) { + case 'script': + case 'flow': + return type + case 'app': + return 'raw_app' + case 'schedule': + return 'trigger_schedule' + case 'trigger': + return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined + } } function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue { @@ -39,6 +97,18 @@ function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue { } } +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', @@ -75,6 +145,31 @@ function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceI } } +function scheduleDraftToWorkspaceItem(path: string, draft: NewSchedule): WorkspaceItem { + return { + type: 'schedule', + path, + summary: draft.summary ?? undefined, + value: clone(draft), + isDraft: true + } +} + +function triggerDraftToWorkspaceItem( + kind: TriggerKind, + path: string, + draft: TriggerRequestBody +): WorkspaceItem { + return { + type: 'trigger', + triggerKind: kind, + path, + summary: getItemSummary(draft), + value: clone(draft), + isDraft: true + } +} + function sharedDraftEntryToWorkspaceItem(entry: UserDraftListEntry): WorkspaceItem | undefined { switch (entry.itemKind) { case 'script': @@ -83,8 +178,13 @@ function sharedDraftEntryToWorkspaceItem(entry: UserDraftListEntry): WorkspaceIt return flowDraftToWorkspaceItem(entry.path, entry.value as Flow) case 'raw_app': return appDraftToWorkspaceItem(entry.path, entry.value as AppDraftValue) + case 'trigger_schedule': + return scheduleDraftToWorkspaceItem(entry.path, entry.value as NewSchedule) default: - return undefined + const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind] + return triggerKind + ? triggerDraftToWorkspaceItem(triggerKind, entry.path, entry.value as TriggerRequestBody) + : undefined } } @@ -160,6 +260,33 @@ function appItemToUserDraft(item: WorkspaceItem): AppDraftValue { }) } +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, @@ -181,9 +308,11 @@ async function loadExistingFlow( function getSharedDraft( workspace: string, type: SharedWorkspaceItemType, - path: string + path: string, + triggerKind?: TriggerKind ): WorkspaceItem | undefined { - const itemKind = sharedDraftKind(type) + const itemKind = sharedDraftKind(type, triggerKind) + if (!itemKind) return undefined const draft = UserDraft.get(itemKind, path, { workspace }) if (draft === undefined) return undefined @@ -194,6 +323,12 @@ function getSharedDraft( return flowDraftToWorkspaceItem(path, draft as Flow) case 'app': return appDraftToWorkspaceItem(path, draft as AppDraftValue) + case 'schedule': + return scheduleDraftToWorkspaceItem(path, draft as NewSchedule) + case 'trigger': + return triggerKind + ? triggerDraftToWorkspaceItem(triggerKind, path, draft as TriggerRequestBody) + : undefined } } @@ -224,13 +359,35 @@ async function setSharedDraft( UserDraft.save('raw_app', item.path, draft, { workspace }) return appDraftToWorkspaceItem(item.path, draft) } + case 'schedule': { + const draft = scheduleItemToUserDraft(item) + UserDraft.save('trigger_schedule', item.path, draft, { workspace }) + return scheduleDraftToWorkspaceItem(item.path, draft) + } + case 'trigger': { + if (!item.triggerKind) { + throw new Error(`Draft trigger "${item.path}" is missing trigger kind.`) + } + const draft = triggerItemToUserDraft(item) + UserDraft.save(TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[item.triggerKind], item.path, draft, { + workspace + }) + return triggerDraftToWorkspaceItem(item.triggerKind, item.path, draft) + } default: throw new Error(`Unsupported shared draft type: ${item.type}`) } } -function deleteSharedDraft(workspace: string, type: SharedWorkspaceItemType, path: string): void { - UserDraft.remove(sharedDraftKind(type), path, { workspace }) +function deleteSharedDraft( + workspace: string, + type: SharedWorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): void { + const itemKind = sharedDraftKind(type, triggerKind) + if (!itemKind) return + UserDraft.remove(itemKind, path, { workspace }) } export function getGlobalDraft( @@ -240,7 +397,7 @@ export function getGlobalDraft( triggerKind?: TriggerKind ): WorkspaceItem | undefined { if (isSharedWorkspaceItemType(type)) { - const shared = getSharedDraft(workspace, type, path) + const shared = getSharedDraft(workspace, type, path, triggerKind) if (shared) return shared } return globalDraftStore.getDraft(workspace, type, path, triggerKind) @@ -290,7 +447,7 @@ export function deleteGlobalDraft( triggerKind?: TriggerKind ): void { if (isSharedWorkspaceItemType(type)) { - deleteSharedDraft(workspace, type, path) + deleteSharedDraft(workspace, type, path, triggerKind) } globalDraftStore.deleteDraft(workspace, type, path, triggerKind) }