/** * Workflow API (GraphicalEditor) * Node types and graph execution for n8n-style flows. */ import type { ApiRequestOptions } from '../hooks/useApi'; const LOG = '[Workflow]'; // ============================================================================ // TYPES // ============================================================================ export interface NodeTypeParameter { name: string; type: string; required?: boolean; description?: string; default?: unknown; frontendType?: string; frontendOptions?: Record; options?: unknown[]; validation?: Record; } export interface PortField { name: string; type: string; description: Record; required: boolean; } export interface PortSchema { name: string; fields: PortField[]; } export interface InputPortDef { accepts: string[]; } export interface OutputPortDef { schema: string; dynamic?: boolean; deriveFrom?: string; } export interface NodeType { id: string; category: string; label: string; description: string; parameters: NodeTypeParameter[]; inputs: number; outputs: number; outputLabels?: string[]; executor: string; inputPorts?: Record; outputPorts?: Record; meta?: { icon?: string; color?: string; /** True if this node performs an LLM / AI call (credits). */ usesAi?: boolean; method?: string; action?: string; }; } export interface NodeTypeCategory { id: string; label: Record | string; } export interface SystemVariable { type: string; description: string; } export interface NodeTypesResponse { nodeTypes: NodeType[]; categories: NodeTypeCategory[]; portTypeCatalog?: Record; systemVariables?: Record; } export interface Automation2GraphNode { id: string; type: string; parameters?: Record; inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>; outputPorts?: Array<{ name: string; schema: string }>; } export interface Automation2Connection { source: string; target: string; sourceOutput?: number; targetInput?: number; } export interface Automation2Graph { nodes: Automation2GraphNode[]; connections: Automation2Connection[]; } export interface ExecuteGraphResponse { success: boolean; nodeOutputs?: Record; error?: string; stopped?: boolean; failedNode?: string; paused?: boolean; taskId?: string; runId?: string; nodeId?: string; } /** Entry point / start configured outside the canvas (manual, form, schedule, …) */ export interface WorkflowEntryPoint { id: string; kind: string; category: 'on_demand' | 'always_on'; enabled: boolean; title: Record | string; description?: Record; config: Record; } export interface Automation2Workflow { id: string; label: string; graph: Automation2Graph; active?: boolean; /** Entry points (Starts) — how this workflow may be invoked */ invocations?: WorkflowEntryPoint[]; /** Enriched: run count */ runCount?: number; /** Enriched: has active (running/paused) run */ isRunning?: boolean; /** Enriched: status of active run */ runStatus?: string; /** Enriched: nodeId where workflow is stuck (paused) */ stuckAtNodeId?: string; /** Enriched: human-readable label for stuck node */ stuckAtNodeLabel?: string; /** Enriched: created timestamp (seconds) */ createdAt?: number; /** Enriched: last run started timestamp (seconds) */ lastStartedAt?: number; } // ============================================================================ // AUTO-PREFIX TYPES (Greenfield) // ============================================================================ export type AutoWorkflowStatus = 'draft' | 'published' | 'archived'; export type AutoRunStatus = 'running' | 'paused' | 'completed' | 'failed' | 'cancelled'; export type AutoStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped'; export type AutoTaskStatus = 'pending' | 'completed' | 'cancelled' | 'expired'; export type AutoTemplateScope = 'user' | 'instance' | 'mandate' | 'system'; export interface AutoVersion { id: string; workflowId: string; versionNumber: number; status: AutoWorkflowStatus; graph: Automation2Graph; invocations?: WorkflowEntryPoint[]; publishedAt?: number; publishedBy?: string; } export interface AutoRun { id: string; workflowId: string; versionId?: string; status: AutoRunStatus; trigger?: Record; startedAt?: number; completedAt?: number; nodeOutputs?: Record; currentNodeId?: string; resumeContext?: Record; error?: string; costTokens?: number; costCredits?: number; } export interface AutoWorkflow { id: string; mandateId: string; featureInstanceId: string; label: string; description?: string; tags?: string[]; isTemplate: boolean; templateSourceId?: string; templateScope?: AutoTemplateScope; sharedReadOnly?: boolean; currentVersionId?: string; active: boolean; eventId?: string; notifyOnFailure?: boolean; graph: Automation2Graph; invocations?: WorkflowEntryPoint[]; sysCreatedBy?: string; sysCreatedAt?: number; sysModifiedBy?: string; sysModifiedAt?: number; } export interface AutoTask { id: string; runId: string; workflowId: string; nodeId: string; nodeType: string; config: Record; assigneeId?: string; status: AutoTaskStatus; result?: Record; expiresAt?: number; sysCreatedAt?: number; } export interface AutoStepLog { id: string; runId: string; nodeId: string; nodeType: string; status: AutoStepStatus; inputSnapshot?: Record; output?: Record; error?: string; startedAt?: number; completedAt?: number; durationMs?: number; tokensUsed?: number; retryCount?: number; } // ============================================================================ // API FUNCTIONS // ============================================================================ export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; /** * Fetch node types for the flow builder (backend-driven). * GET /api/workflows/{instanceId}/node-types?language=de */ export async function fetchNodeTypes( request: ApiRequestFunction, instanceId: string, language = 'de' ): Promise { console.log(`${LOG} fetchNodeTypes: instanceId=${instanceId} language=${language}`); const data = await request({ url: `/api/workflows/${instanceId}/node-types`, method: 'get', params: { language }, }); const nodeTypes = data?.nodeTypes ?? []; const categories = data?.categories ?? []; console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`); return { nodeTypes, categories }; } /** * Execute an automation2 graph. * POST /api/workflows/{instanceId}/execute */ export interface ExecuteGraphOptions { /** Use a configured start on the saved workflow */ entryPointId?: string; /** Full run envelope (overrides entry point mapping) */ runEnvelope?: Record; /** Merged into envelope.payload */ payload?: Record; } export async function executeGraph( request: ApiRequestFunction, instanceId: string, graph: Automation2Graph, workflowId?: string, options?: ExecuteGraphOptions ): Promise { console.log( `${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`, { nodes: graph.nodes, connections: graph.connections, options } ); const start = performance.now(); try { const data: Record = { graph, workflowId }; if (options?.entryPointId) data.entryPointId = options.entryPointId; if (options?.runEnvelope) data.runEnvelope = options.runEnvelope; if (options?.payload && Object.keys(options.payload).length > 0) data.payload = options.payload; const result = await request({ url: `/api/workflows/${instanceId}/execute`, method: 'post', data, }); const ms = Math.round(performance.now() - start); console.log( `${LOG} executeGraph response (${ms}ms): success=${result?.success} error=${result?.error ?? 'none'} nodeOutputs_keys=${Object.keys(result?.nodeOutputs ?? {}).join(',')} failedNode=${result?.failedNode ?? '-'}`, result ); return result; } catch (err) { const ms = Math.round(performance.now() - start); console.error( `${LOG} executeGraph FAILED (${ms}ms): instanceId=${instanceId}`, err ); throw err; } } // ------------------------------------------------------------------------- // Workflows CRUD // ------------------------------------------------------------------------- export async function fetchWorkflows( request: ApiRequestFunction, instanceId: string, params?: { active?: boolean; pagination?: any } ): Promise { const queryParams: Record = {}; if (params?.active !== undefined) queryParams.active = params.active; if (params?.pagination) queryParams.pagination = JSON.stringify(params.pagination); const data = await request({ url: `/api/workflows/${instanceId}/workflows`, method: 'get', params: Object.keys(queryParams).length > 0 ? queryParams : undefined, }); if (data?.items && data?.pagination) return data; return data?.workflows ?? []; } export async function fetchWorkflow( request: ApiRequestFunction, instanceId: string, workflowId: string ): Promise { return await request({ url: `/api/workflows/${instanceId}/workflows/${workflowId}`, method: 'get', }); } export async function createWorkflow( request: ApiRequestFunction, instanceId: string, body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] } ): Promise { return await request({ url: `/api/workflows/${instanceId}/workflows`, method: 'post', data: body, }); } export async function updateWorkflow( request: ApiRequestFunction, instanceId: string, workflowId: string, body: { label?: string; graph?: Automation2Graph; invocations?: WorkflowEntryPoint[]; active?: boolean; notifyOnFailure?: boolean; } ): Promise { return await request({ url: `/api/workflows/${instanceId}/workflows/${workflowId}`, method: 'put', data: body, }); } export async function deleteWorkflow( request: ApiRequestFunction, instanceId: string, workflowId: string ): Promise { await request({ url: `/api/workflows/${instanceId}/workflows/${workflowId}`, method: 'delete', }); } // ------------------------------------------------------------------------- // Workflow file IO (envelopeVersioned, .workflow.json) // ------------------------------------------------------------------------- /** envelopeVersioned schema 1.0 — keys mirror the gateway constants. */ export const WORKFLOW_FILE_SCHEMA_VERSION = '1.0'; export const WORKFLOW_FILE_KIND = 'poweron.workflow'; export const WORKFLOW_FILE_EXTENSION = '.workflow.json'; export interface WorkflowFileEnvelope { $schemaVersion: string; $kind: string; $exportedAt?: string; $gatewayVersion?: string; label: string; description?: string; tags?: string[]; templateScope?: AutoTemplateScope; sharedReadOnly?: boolean; notifyOnFailure?: boolean; graph: Automation2Graph; invocations?: WorkflowEntryPoint[]; } export interface ImportWorkflowResponse { workflow: AutoWorkflow; warnings: string[]; created: boolean; } export interface ImportWorkflowOptions { /** Inline envelope payload (preferred for round-trip in the editor). */ envelope?: WorkflowFileEnvelope; /** UDB FileItem.id of a previously uploaded ``.workflow.json``. */ fileId?: string; /** When set, the existing workflow is replaced instead of a new one being created. */ existingWorkflowId?: string; } /** POST /api/workflows/{instanceId}/workflows/import */ export async function importWorkflowFromFile( request: ApiRequestFunction, instanceId: string, options: ImportWorkflowOptions, ): Promise { if (!options.envelope && !options.fileId) { throw new Error('importWorkflowFromFile: either envelope or fileId is required'); } return await request({ url: `/api/workflows/${instanceId}/workflows/import`, method: 'post', data: options, }); } export interface ExportWorkflowResult { fileName: string; envelope: WorkflowFileEnvelope; } /** * GET /api/workflows/{instanceId}/workflows/{workflowId}/export * * The backend returns ``{ fileName, envelope }`` when ``download=false`` and a * raw JSON download (``Content-Disposition: attachment``) when ``download=true``. * For programmatic use (e.g. re-uploading to UDB) keep download=false. */ export async function exportWorkflowToFile( request: ApiRequestFunction, instanceId: string, workflowId: string, download = false, ): Promise { return await request({ url: `/api/workflows/${instanceId}/workflows/${workflowId}/export`, method: 'get', params: { download }, }); } /** Quick content-sniffing — used by the UDB FilesTab to flag workflow files. */ export function isWorkflowFileContent(payload: unknown): boolean { if (!payload || typeof payload !== 'object') return false; const p = payload as Record; return ( typeof p.$schemaVersion === 'string' && p.$kind === WORKFLOW_FILE_KIND && !!p.graph && typeof p.graph === 'object' ); } /** Suggest a safe filename from the workflow label (mirrors gateway buildFileName). */ export function workflowFileNameFor(label: string): string { const slug = (label || 'workflow') .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80) || 'workflow'; return `${slug}${WORKFLOW_FILE_EXTENSION}`; } /** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */ export async function deleteSystemWorkflow( request: ApiRequestFunction, workflowId: string, ): Promise { await request({ url: `/api/system/workflow-runs/workflows/${workflowId}`, method: 'delete', }); } export interface Automation2Run { id: string; workflowId: string; status: string; nodeOutputs?: Record; currentNodeId?: string; } export async function fetchWorkflowRuns( request: ApiRequestFunction, instanceId: string, workflowId: string ): Promise { const data = await request({ url: `/api/workflows/${instanceId}/workflows/${workflowId}/runs`, method: 'get', }); return data?.runs ?? []; } export interface CompletedRun extends Automation2Run { workflowLabel?: string; sysModifiedAt?: number; sysCreatedAt?: number; } export async function fetchCompletedRuns( request: ApiRequestFunction, instanceId: string, limit = 20 ): Promise { const data = await request({ url: `/api/workflows/${instanceId}/runs/completed`, method: 'get', params: { limit }, }); return data?.runs ?? []; } // ------------------------------------------------------------------------- // Tasks // ------------------------------------------------------------------------- export interface Automation2Task { id: string; runId: string; workflowId: string; nodeId: string; nodeType: string; config: Record; status: string; result?: Record; /** Workflow label (enriched by API) */ workflowLabel?: string; /** Unix timestamp ms (from sysCreatedAt) */ createdAt?: number; /** Optional due date - configurable in future */ dueAt?: number; } export async function fetchTasks( request: ApiRequestFunction, instanceId: string, params?: { workflowId?: string; status?: string } ): Promise { const data = await request({ url: `/api/workflows/${instanceId}/tasks`, method: 'get', params, }); return data?.tasks ?? []; } export async function completeTask( request: ApiRequestFunction, instanceId: string, taskId: string, result: Record ): Promise { return await request({ url: `/api/workflows/${instanceId}/tasks/${taskId}/complete`, method: 'post', data: { result }, }); } // ------------------------------------------------------------------------- // Versions (AutoVersion Lifecycle) // ------------------------------------------------------------------------- export async function fetchVersions( request: ApiRequestFunction, instanceId: string, workflowId: string ): Promise { const data = await request({ url: `/api/workflows/${instanceId}/workflows/${workflowId}/versions`, method: 'get', }); return data?.versions ?? []; } export async function createDraftVersion( request: ApiRequestFunction, instanceId: string, workflowId: string ): Promise { return await request({ url: `/api/workflows/${instanceId}/workflows/${workflowId}/versions/draft`, method: 'post', }); } export async function publishVersion( request: ApiRequestFunction, instanceId: string, versionId: string ): Promise { return await request({ url: `/api/workflows/${instanceId}/versions/${versionId}/publish`, method: 'post', }); } export async function unpublishVersion( request: ApiRequestFunction, instanceId: string, versionId: string ): Promise { return await request({ url: `/api/workflows/${instanceId}/versions/${versionId}/unpublish`, method: 'post', }); } export async function archiveVersion( request: ApiRequestFunction, instanceId: string, versionId: string ): Promise { return await request({ url: `/api/workflows/${instanceId}/versions/${versionId}/archive`, method: 'post', }); } // ------------------------------------------------------------------------- // Templates // ------------------------------------------------------------------------- export interface AutoWorkflowTemplate extends Automation2Workflow { isTemplate: boolean; templateScope?: AutoTemplateScope; templateSourceId?: string; sharedReadOnly?: boolean; } export async function fetchTemplates( request: ApiRequestFunction, instanceId: string, scope?: AutoTemplateScope, pagination?: any ): Promise { const queryParams: Record = {}; if (scope) queryParams.scope = scope; if (pagination) queryParams.pagination = JSON.stringify(pagination); const data = await request({ url: `/api/workflows/${instanceId}/templates`, method: 'get', params: Object.keys(queryParams).length > 0 ? queryParams : undefined, }); if (data?.items && data?.pagination) return data; return data?.templates ?? []; } export async function createTemplateFromWorkflow( request: ApiRequestFunction, instanceId: string, workflowId: string, scope: AutoTemplateScope = 'user' ): Promise { return await request({ url: `/api/workflows/${instanceId}/templates/from-workflow`, method: 'post', data: { workflowId, scope }, }); } export async function copyTemplate( request: ApiRequestFunction, instanceId: string, templateId: string ): Promise { return await request({ url: `/api/workflows/${instanceId}/templates/${templateId}/copy`, method: 'post', }); } export async function shareTemplate( request: ApiRequestFunction, instanceId: string, templateId: string, scope: AutoTemplateScope ): Promise { return await request({ url: `/api/workflows/${instanceId}/templates/${templateId}/share`, method: 'post', data: { scope }, }); } // ------------------------------------------------------------------------- // Connections and Browse (for Email/SharePoint node config) // ------------------------------------------------------------------------- export interface UserConnection { id: string; authority: string; externalUsername?: string; externalEmail?: string; status: string; } export async function fetchConnections( request: ApiRequestFunction, instanceId: string ): Promise { const data = await request({ url: `/api/workflows/${instanceId}/connections`, method: 'get', }); return data?.connections ?? []; } export interface ConnectionService { service: string; label: string; icon: string; } export async function fetchConnectionServices( request: ApiRequestFunction, instanceId: string, connectionId: string ): Promise { const data = await request({ url: `/api/workflows/${instanceId}/connections/${connectionId}/services`, method: 'get', }); return data?.services ?? []; } export interface BrowseEntry { name: string; path: string; isFolder: boolean; size?: number; mimeType?: string; metadata?: Record; } export async function fetchBrowse( request: ApiRequestFunction, instanceId: string, connectionId: string, service: string, path = '/' ): Promise<{ items: BrowseEntry[]; path: string; service: string }> { const data = await request({ url: `/api/workflows/${instanceId}/connections/${connectionId}/browse`, method: 'get', params: { service, path }, }); return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service }; } /** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */ export async function fetchClickupTask( request: ApiRequestFunction, connectionId: string, taskId: string ): Promise> { const data = await request({ url: `/api/clickup/${connectionId}/tasks/${encodeURIComponent(taskId)}`, method: 'get', }); return data && typeof data === 'object' ? (data as Record) : {}; } /** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */ export async function fetchClickupList( request: ApiRequestFunction, connectionId: string, listId: string ): Promise> { const data = await request({ url: `/api/clickup/${connectionId}/lists/${listId}`, method: 'get', }); return data && typeof data === 'object' ? (data as Record) : {}; } /** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */ export async function fetchClickupTeam( request: ApiRequestFunction, connectionId: string, teamId: string ): Promise> { const data = await request({ url: `/api/clickup/${connectionId}/teams/${teamId}`, method: 'get', }); return data && typeof data === 'object' ? (data as Record) : {}; } /** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */ export async function fetchClickupListFields( request: ApiRequestFunction, connectionId: string, listId: string ): Promise<{ fields?: unknown[] } & Record> { const data = await request({ url: `/api/clickup/${connectionId}/lists/${listId}/fields`, method: 'get', }); return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record; } /** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */ export interface ClickupListTaskItem { id?: string; name?: string; } export async function fetchClickupListTasks( request: ApiRequestFunction, connectionId: string, listId: string, options?: { page?: number; includeClosed?: boolean } ): Promise< { tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record > { const data = await request({ url: `/api/clickup/${connectionId}/lists/${listId}/tasks`, method: 'get', params: { page: options?.page ?? 0, include_closed: options?.includeClosed ?? false, }, }); return (data && typeof data === 'object' ? data : {}) as { tasks?: ClickupListTaskItem[]; last_page?: boolean; } & Record; } // ------------------------------------------------------------------------- // Monitoring / Metrics // ------------------------------------------------------------------------- export interface WorkflowMetrics { workflowCount: number; activeWorkflows: number; totalRuns: number; runsByStatus: Record; totalTasks: number; tasksByStatus: Record; totalTokens: number; totalCredits: number; } export async function fetchMetrics( request: ApiRequestFunction, instanceId: string ): Promise { return await request({ url: `/api/workflows/${instanceId}/metrics`, method: 'get', }); } /** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe“. */ export async function loadClickupListTasksForDropdown( request: ApiRequestFunction, connectionId: string, listId: string ): Promise> { const acc: Array<{ id: string; name: string }> = []; const seen = new Set(); const maxPages = 12; const pageSizeHint = 100; for (let page = 0; page < maxPages; page++) { const data = await fetchClickupListTasks(request, connectionId, listId, { page, includeClosed: false, }); if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) { const err = (data as { error?: unknown }).error; const body = (data as { body?: string }).body; throw new Error( typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error' ); } const tasks = Array.isArray(data.tasks) ? data.tasks : []; for (const t of tasks) { const id = t?.id != null ? String(t.id) : ''; if (!id || seen.has(id)) continue; seen.add(id); acc.push({ id, name: String(t.name ?? id) }); } const rawLast = (data as Record).last_page; const last = rawLast === true || rawLast === 'true' || tasks.length === 0 || tasks.length < pageSizeHint; if (last) break; } acc.sort((a, b) => a.name.localeCompare(b.name, 'de')); return acc; }