diff --git a/lib/steps/create-ticket.ts b/lib/steps/create-ticket.ts index 52a9a03ed..6e6f49719 100644 --- a/lib/steps/create-ticket.ts +++ b/lib/steps/create-ticket.ts @@ -8,12 +8,17 @@ import "server-only"; import { LinearClient } from "@linear/sdk"; import { fetchCredentials } from "../credential-fetcher"; +import { getErrorMessage } from "../utils"; + +type CreateTicketResult = + | { success: true; id: string; url: string; title: string } + | { success: false; error: string }; export async function createTicketStep(input: { integrationId?: string; ticketTitle: string; ticketDescription: string; -}) { +}): Promise { "use step"; const credentials = input.integrationId @@ -24,38 +29,54 @@ export async function createTicketStep(input: { const teamId = credentials.LINEAR_TEAM_ID; if (!apiKey) { - throw new Error( - "LINEAR_API_KEY is not configured. Please add it in Project Integrations." - ); + return { + success: false, + error: + "LINEAR_API_KEY is not configured. Please add it in Project Integrations.", + }; } - const linear = new LinearClient({ apiKey }); + try { + const linear = new LinearClient({ apiKey }); - let targetTeamId = teamId; - if (!targetTeamId) { - const teams = await linear.teams(); - const firstTeam = teams.nodes[0]; - if (!firstTeam) { - throw new Error("No teams found in Linear workspace"); + let targetTeamId = teamId; + if (!targetTeamId) { + const teams = await linear.teams(); + const firstTeam = teams.nodes[0]; + if (!firstTeam) { + return { + success: false, + error: "No teams found in Linear workspace", + }; + } + targetTeamId = firstTeam.id; } - targetTeamId = firstTeam.id; - } - const issuePayload = await linear.createIssue({ - title: input.ticketTitle, - description: input.ticketDescription, - teamId: targetTeamId, - }); + const issuePayload = await linear.createIssue({ + title: input.ticketTitle, + description: input.ticketDescription, + teamId: targetTeamId, + }); - const issue = await issuePayload.issue; + const issue = await issuePayload.issue; - if (!issue) { - throw new Error("Failed to create issue"); - } + if (!issue) { + return { + success: false, + error: "Failed to create issue", + }; + } - return { - id: issue.id, - url: issue.url, - title: issue.title, - }; + return { + success: true, + id: issue.id, + url: issue.url, + title: issue.title, + }; + } catch (error) { + return { + success: false, + error: `Failed to create ticket: ${getErrorMessage(error)}`, + }; + } } diff --git a/lib/steps/database-query.ts b/lib/steps/database-query.ts index 7ff9e3f19..ddf7068b1 100644 --- a/lib/steps/database-query.ts +++ b/lib/steps/database-query.ts @@ -17,12 +17,9 @@ type DatabaseQueryInput = { query?: string; }; -type DatabaseQueryResult = { - status: string; - rows?: unknown; - count?: number; - error?: string; -}; +type DatabaseQueryResult = + | { success: true; rows: unknown; count: number } + | { success: false; error: string }; function validateInput(input: DatabaseQueryInput): string | null { const queryString = input.dbQuery || input.query; @@ -50,7 +47,7 @@ async function executeQuery( return await db.execute(sql.raw(queryString)); } -function getErrorMessage(error: unknown): string { +function getDatabaseErrorMessage(error: unknown): string { if (!(error instanceof Error)) { return "Unknown database error"; } @@ -90,10 +87,7 @@ export async function databaseQueryStep( const validationError = validateInput(input); if (validationError) { - return { - status: "error", - error: validationError, - }; + return { success: false, error: validationError }; } const credentials = input.integrationId @@ -104,7 +98,7 @@ export async function databaseQueryStep( if (!databaseUrl) { return { - status: "error", + success: false, error: "DATABASE_URL is not configured. Please add it in Project Integrations.", }; @@ -119,15 +113,15 @@ export async function databaseQueryStep( await client.end(); return { - status: "success", + success: true, rows: result, count: Array.isArray(result) ? result.length : 0, }; } catch (error) { await cleanupClient(client); return { - status: "error", - error: getErrorMessage(error), + success: false, + error: `Database query failed: ${getDatabaseErrorMessage(error)}`, }; } } diff --git a/lib/steps/generate-image.ts b/lib/steps/generate-image.ts index 117e2388a..7ac212735 100644 --- a/lib/steps/generate-image.ts +++ b/lib/steps/generate-image.ts @@ -6,14 +6,20 @@ */ import "server-only"; -import { experimental_generateImage as generateImage } from "ai"; +import type { ImageModelV2 } from "@ai-sdk/provider"; +import { createGateway, experimental_generateImage as generateImage } from "ai"; import { fetchCredentials } from "../credential-fetcher"; +import { getErrorMessageAsync } from "../utils"; + +type GenerateImageResult = + | { success: true; base64: string } + | { success: false; error: string }; export async function generateImageStep(input: { integrationId?: string; - model: string; - prompt: string; -}): Promise<{ base64: string | undefined }> { + imageModel: ImageModelV2; + imagePrompt: string; +}): Promise { "use step"; const credentials = input.integrationId @@ -23,29 +29,43 @@ export async function generateImageStep(input: { const apiKey = credentials.AI_GATEWAY_API_KEY; if (!apiKey) { - throw new Error( - "AI_GATEWAY_API_KEY is not configured. Please add it in Project Integrations." - ); + return { + success: false, + error: + "AI_GATEWAY_API_KEY is not configured. Please add it in Project Integrations.", + }; } - const result = await generateImage({ - // biome-ignore lint/suspicious/noExplicitAny: model string needs type coercion for ai package - model: input.model as any, - prompt: input.prompt, - size: "1024x1024", - providerOptions: { - openai: { - apiKey, - }, - }, - }); - - if (!result.image) { - throw new Error("Failed to generate image"); - } + try { + const gateway = createGateway({ + apiKey, + }); + + // biome-ignore lint/suspicious/noExplicitAny: AI gateway model ID is dynamic + const modelId = (input.imageModel ?? "bfl/flux-2-pro") as any; + const result = await generateImage({ + model: gateway.imageModel(modelId), + prompt: input.imagePrompt, + size: "1024x1024", + }); - // Convert the GeneratedFile to base64 string - const base64 = result.image.toString(); + if (!result.image) { + return { + success: false, + error: "Failed to generate image: No image returned", + }; + } - return { base64 }; + // Convert the GeneratedFile to base64 string + const base64 = result.image.toString(); + + return { success: true, base64 }; + } catch (error) { + // Extract meaningful error message from AI SDK errors + const message = await getErrorMessageAsync(error); + return { + success: false, + error: `Image generation failed: ${message}`, + }; + } } diff --git a/lib/steps/generate-text.ts b/lib/steps/generate-text.ts index 0416393b5..a1d145d88 100644 --- a/lib/steps/generate-text.ts +++ b/lib/steps/generate-text.ts @@ -10,12 +10,18 @@ import "server-only"; import { generateObject, generateText } from "ai"; import { z } from "zod"; import { fetchCredentials } from "../credential-fetcher"; +import { getErrorMessageAsync } from "../utils"; type SchemaField = { name: string; type: string; }; +type GenerateTextResult = + | { success: true; text: string } + | { success: true; object: Record } + | { success: false; error: string }; + /** * Determines the provider from the model ID */ @@ -56,7 +62,7 @@ export async function generateTextStep(input: { aiPrompt?: string; aiFormat?: string; aiSchema?: string; -}): Promise<{ text: string } | Record> { +}): Promise { "use step"; const credentials = input.integrationId @@ -66,23 +72,28 @@ export async function generateTextStep(input: { const apiKey = credentials.AI_GATEWAY_API_KEY; if (!apiKey) { - throw new Error( - "AI_GATEWAY_API_KEY is not configured. Please add it in Project Integrations." - ); + return { + success: false, + error: + "AI_GATEWAY_API_KEY is not configured. Please add it in Project Integrations.", + }; } const modelId = input.aiModel || "gpt-5"; const promptText = input.aiPrompt || ""; if (!promptText || promptText.trim() === "") { - throw new Error("Prompt is required for text generation"); + return { + success: false, + error: "Prompt is required for text generation", + }; } const providerName = getProviderFromModel(modelId); const modelString = `${providerName}/${modelId}`; - if (input.aiFormat === "object" && input.aiSchema) { - try { + try { + if (input.aiFormat === "object" && input.aiSchema) { const schema = JSON.parse(input.aiSchema) as SchemaField[]; const zodSchema = buildZodSchema(schema); @@ -95,19 +106,24 @@ export async function generateTextStep(input: { }, }); - return object; - } catch { - // If structured output fails, fall back to text generation + return { success: true, object }; } - } - const { text } = await generateText({ - model: modelString, - prompt: promptText, - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); - - return { text }; + const { text } = await generateText({ + model: modelString, + prompt: promptText, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + return { success: true, text }; + } catch (error) { + // Extract meaningful error message from AI SDK errors + const message = await getErrorMessageAsync(error); + return { + success: false, + error: `Text generation failed: ${message}`, + }; + } } diff --git a/lib/steps/http-request.ts b/lib/steps/http-request.ts index b91d4c17b..991fb60b7 100644 --- a/lib/steps/http-request.ts +++ b/lib/steps/http-request.ts @@ -3,20 +3,42 @@ */ import "server-only"; +import { getErrorMessage } from "../utils"; + +type HttpRequestResult = + | { success: true; data: unknown; status: number } + | { success: false; error: string; status?: number }; + export async function httpRequestStep(input: { url: string; method: string; headers: Record; body: unknown; -}): Promise { +}): Promise { "use step"; - const response = await fetch(input.url, { - method: input.method, - headers: input.headers, - body: input.body ? JSON.stringify(input.body) : undefined, - }); + try { + const response = await fetch(input.url, { + method: input.method, + headers: input.headers, + body: input.body ? JSON.stringify(input.body) : undefined, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + return { + success: false, + error: `HTTP request failed with status ${response.status}: ${errorText}`, + status: response.status, + }; + } - const data = await response.json(); - return data; + const data = await response.json(); + return { success: true, data, status: response.status }; + } catch (error) { + return { + success: false, + error: `HTTP request failed: ${getErrorMessage(error)}`, + }; + } } diff --git a/lib/steps/send-email.ts b/lib/steps/send-email.ts index 60ee142b3..58ca32fd1 100644 --- a/lib/steps/send-email.ts +++ b/lib/steps/send-email.ts @@ -8,64 +8,77 @@ * - Step output contains no credentials * * This ensures: - * ✓ Credentials are never logged in Vercel's workflow observability - * ✓ Works for both production (process.env) and test runs (Vercel API fetch) - * ✓ Follows Vercel Workflow DevKit best practices + * - Credentials are never logged in Vercel's workflow observability + * - Works for both production (process.env) and test runs (Vercel API fetch) + * - Follows Vercel Workflow DevKit best practices */ import "server-only"; import { Resend } from "resend"; import { fetchCredentials } from "../credential-fetcher"; +import { getErrorMessage } from "../utils"; + +type SendEmailResult = + | { success: true; id: string } + | { success: false; error: string }; export async function sendEmailStep(input: { integrationId?: string; // Reference to fetch credentials (safe to log) emailTo: string; emailSubject: string; emailBody: string; -}) { +}): Promise { "use step"; - console.log( - "[Send Email] Step called with integrationId:", - input.integrationId - ); - // SECURITY: Fetch credentials using the integration ID reference // This happens in a secure, non-persisted context (not logged by observability) const credentials = input.integrationId ? await fetchCredentials(input.integrationId) : {}; - console.log( - "[Send Email] Credentials fetched, has API key:", - !!credentials.RESEND_API_KEY - ); - const apiKey = credentials.RESEND_API_KEY; const fromEmail = credentials.RESEND_FROM_EMAIL; if (!apiKey) { - throw new Error( - "RESEND_API_KEY is not configured. Please add it in Project Integrations." - ); + return { + success: false, + error: + "RESEND_API_KEY is not configured. Please add it in Project Integrations.", + }; } if (!fromEmail) { - throw new Error( - "RESEND_FROM_EMAIL is not configured. Please add it in Project Integrations." - ); + return { + success: false, + error: + "RESEND_FROM_EMAIL is not configured. Please add it in Project Integrations.", + }; } - // Use credentials in memory only - const resend = new Resend(apiKey); + try { + // Use credentials in memory only + const resend = new Resend(apiKey); - const result = await resend.emails.send({ - from: fromEmail, - to: input.emailTo, - subject: input.emailSubject, - text: input.emailBody, - }); + const result = await resend.emails.send({ + from: fromEmail, + to: input.emailTo, + subject: input.emailSubject, + text: input.emailBody, + }); - // Return result WITHOUT credentials (safe to log) - return result; + if (result.error) { + return { + success: false, + error: result.error.message || "Failed to send email", + }; + } + + // Return result WITHOUT credentials (safe to log) + return { success: true, id: result.data?.id || "" }; + } catch (error) { + return { + success: false, + error: `Failed to send email: ${getErrorMessage(error)}`, + }; + } } diff --git a/lib/steps/send-slack-message.ts b/lib/steps/send-slack-message.ts index 25369971d..d9fc86bdd 100644 --- a/lib/steps/send-slack-message.ts +++ b/lib/steps/send-slack-message.ts @@ -8,12 +8,17 @@ import "server-only"; import { WebClient } from "@slack/web-api"; import { fetchCredentials } from "../credential-fetcher"; +import { getErrorMessage } from "../utils"; + +type SendSlackMessageResult = + | { success: true; ts: string; channel: string } + | { success: false; error: string }; export async function sendSlackMessageStep(input: { integrationId?: string; slackChannel: string; slackMessage: string; -}) { +}): Promise { "use step"; const credentials = input.integrationId @@ -23,17 +28,37 @@ export async function sendSlackMessageStep(input: { const apiKey = credentials.SLACK_API_KEY; if (!apiKey) { - throw new Error( - "SLACK_API_KEY is not configured. Please add it in Project Integrations." - ); + return { + success: false, + error: + "SLACK_API_KEY is not configured. Please add it in Project Integrations.", + }; } - const slack = new WebClient(apiKey); + try { + const slack = new WebClient(apiKey); + + const result = await slack.chat.postMessage({ + channel: input.slackChannel, + text: input.slackMessage, + }); - const result = await slack.chat.postMessage({ - channel: input.slackChannel, - text: input.slackMessage, - }); + if (!result.ok) { + return { + success: false, + error: result.error || "Failed to send Slack message", + }; + } - return result; + return { + success: true, + ts: result.ts || "", + channel: result.channel || "", + }; + } catch (error) { + return { + success: false, + error: `Failed to send Slack message: ${getErrorMessage(error)}`, + }; + } } diff --git a/lib/utils.ts b/lib/utils.ts index 2819a830d..cea660844 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,3 +4,141 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +/** + * Extract a meaningful error message from various error types. + * Handles Error instances, objects with message/error properties, strings, + * and nested error structures common in AI SDKs. + * Note: This is synchronous - use getErrorMessageAsync for Promise errors. + */ +export function getErrorMessage(error: unknown): string { + // Handle null/undefined + if (error === null || error === undefined) { + return "Unknown error"; + } + + // Handle Error instances (and their subclasses) + if (error instanceof Error) { + // Some errors have a cause property with more details + if (error.cause && error.cause instanceof Error) { + return `${error.message}: ${error.cause.message}`; + } + return error.message; + } + + // Handle strings + if (typeof error === "string") { + return error; + } + + // Handle objects + if (typeof error === "object") { + const obj = error as Record; + + // Check for common error message properties + if (typeof obj.message === "string" && obj.message) { + return obj.message; + } + + // AI SDK often wraps errors in responseBody or data + if (obj.responseBody && typeof obj.responseBody === "object") { + const body = obj.responseBody as Record; + if (typeof body.error === "string") { + return body.error; + } + if ( + body.error && + typeof body.error === "object" && + typeof (body.error as Record).message === "string" + ) { + return (body.error as Record).message as string; + } + } + + // Check for nested error property + if (typeof obj.error === "string" && obj.error) { + return obj.error; + } + if (obj.error && typeof obj.error === "object") { + const nestedError = obj.error as Record; + if (typeof nestedError.message === "string") { + return nestedError.message; + } + } + + // Check for data.error pattern (common in API responses) + if (obj.data && typeof obj.data === "object") { + const data = obj.data as Record; + if (typeof data.error === "string") { + return data.error; + } + if (typeof data.message === "string") { + return data.message; + } + } + + // Check for reason property (common in some error types) + if (typeof obj.reason === "string" && obj.reason) { + return obj.reason; + } + + // Check for statusText (HTTP errors) + if (typeof obj.statusText === "string" && obj.statusText) { + const status = typeof obj.status === "number" ? ` (${obj.status})` : ""; + return `${obj.statusText}${status}`; + } + + // Try to stringify the error object (but avoid [object Object]) + try { + const stringified = JSON.stringify(error, null, 0); + if (stringified && stringified !== "{}" && stringified.length < 500) { + return stringified; + } + } catch { + // Ignore stringify errors + } + + // Last resort: use Object.prototype.toString + const toString = Object.prototype.toString.call(error); + if (toString !== "[object Object]") { + return toString; + } + } + + return "Unknown error"; +} + +/** + * Async version that handles Promise errors by awaiting them first. + * Use this in catch blocks where the error might be a Promise. + */ +export async function getErrorMessageAsync(error: unknown): Promise { + // If error is a Promise, await it to get the actual error + if (error instanceof Promise) { + try { + const resolvedValue = await error; + // The promise resolved - check if it contains error info + return getErrorMessage(resolvedValue); + } catch (rejectedError) { + return getErrorMessage(rejectedError); + } + } + + // Check if it's a thenable (Promise-like) + if ( + error && + typeof error === "object" && + "then" in error && + typeof (error as { then: unknown }).then === "function" + ) { + try { + const resolvedValue = await (error as Promise); + // The promise resolved - check if it contains error info + return getErrorMessage(resolvedValue); + } catch (rejectedError) { + return getErrorMessage(rejectedError); + } + } + + return getErrorMessage(error); +} diff --git a/lib/workflow-executor.server.ts b/lib/workflow-executor.server.ts index 2ddcb0b83..d293315e4 100644 --- a/lib/workflow-executor.server.ts +++ b/lib/workflow-executor.server.ts @@ -6,6 +6,7 @@ import type { SchemaField } from "../components/workflow/config/schema-builder"; import { db } from "./db"; import { workflowExecutionLogs } from "./db/schema"; import { getStep, hasStep } from "./steps"; +import { getErrorMessage, getErrorMessageAsync } from "./utils"; import { redactSensitiveData } from "./utils/redact"; import { type NodeOutputs, processConfigTemplates } from "./utils/template"; import type { WorkflowEdge, WorkflowNode } from "./workflow-store"; @@ -260,7 +261,7 @@ class ServerWorkflowExecutor { } catch (error) { return { success: false, - error: error instanceof Error ? error.message : "Unknown error", + error: getErrorMessage(error), }; } } @@ -357,7 +358,7 @@ class ServerWorkflowExecutor { } catch (error) { return { success: false, - error: `Failed to evaluate condition: ${error instanceof Error ? error.message : "Unknown error"}`, + error: `Failed to evaluate condition: ${getErrorMessage(error)}`, }; } } @@ -520,9 +521,10 @@ class ServerWorkflowExecutor { return result; } catch (error) { + const errorMessage = await getErrorMessageAsync(error); const errorResult = { success: false, - error: error instanceof Error ? error.message : "Unknown error", + error: errorMessage, }; this.results.set(node.id, errorResult); await this.completeNodeExecution( diff --git a/lib/workflow-executor.workflow.ts b/lib/workflow-executor.workflow.ts index fc6e72619..7206c02ad 100644 --- a/lib/workflow-executor.workflow.ts +++ b/lib/workflow-executor.workflow.ts @@ -3,6 +3,7 @@ * This executor captures step executions through the workflow SDK for better observability */ +import { getErrorMessageAsync } from "./utils"; import type { WorkflowEdge, WorkflowNode } from "./workflow-store"; type ExecutionResult = { @@ -88,111 +89,102 @@ async function executeActionStep(input: { // Import and execute the appropriate step function // Step functions load credentials from process.env themselves - try { - if (actionType === "Send Email") { - const { sendEmailStep } = await import("./steps/send-email"); - // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type - return await sendEmailStep(stepInput as any); - } - if (actionType === "Send Slack Message") { - const { sendSlackMessageStep } = await import( - "./steps/send-slack-message" - ); - // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type - return await sendSlackMessageStep(stepInput as any); - } - if (actionType === "Create Ticket") { - const { createTicketStep } = await import("./steps/create-ticket"); - // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type - return await createTicketStep(stepInput as any); - } - if (actionType === "Generate Text") { - const { generateTextStep } = await import("./steps/generate-text"); - // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type - return await generateTextStep(stepInput as any); - } - if (actionType === "Generate Image") { - const { generateImageStep } = await import("./steps/generate-image"); - // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type - return await generateImageStep(stepInput as any); - } - if (actionType === "Database Query") { - const { databaseQueryStep } = await import("./steps/database-query"); - // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type - return await databaseQueryStep(stepInput as any); - } - if (actionType === "HTTP Request") { - const { httpRequestStep } = await import("./steps/http-request"); - // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type - return await httpRequestStep(stepInput as any); - } - if (actionType === "Condition") { - const { conditionStep } = await import("./steps/condition"); - // Special handling for condition: process templates and evaluate as JavaScript - // The condition field is kept as original template string for proper evaluation - const conditionExpression = stepInput.condition; - let evaluatedCondition: boolean; - - console.log("[Condition] Original expression:", conditionExpression); - - if (typeof conditionExpression === "boolean") { - evaluatedCondition = conditionExpression; - } else if (typeof conditionExpression === "string") { - try { - const evalContext: Record = {}; - let transformedExpression = conditionExpression; - const templatePattern = /\{\{@([^:]+):([^}]+)\}\}/g; - const varCounter = { value: 0 }; - - transformedExpression = transformedExpression.replace( - templatePattern, - (match, nodeId, rest) => - replaceTemplateVariable( - match, - nodeId, - rest, - evalContext, - varCounter - ) - ); + if (actionType === "Send Email") { + const { sendEmailStep } = await import("./steps/send-email"); + // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type + return await sendEmailStep(stepInput as any); + } + if (actionType === "Send Slack Message") { + const { sendSlackMessageStep } = await import("./steps/send-slack-message"); + // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type + return await sendSlackMessageStep(stepInput as any); + } + if (actionType === "Create Ticket") { + const { createTicketStep } = await import("./steps/create-ticket"); + // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type + return await createTicketStep(stepInput as any); + } + if (actionType === "Generate Text") { + const { generateTextStep } = await import("./steps/generate-text"); + // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type + return await generateTextStep(stepInput as any); + } + if (actionType === "Generate Image") { + const { generateImageStep } = await import("./steps/generate-image"); + // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type + return await generateImageStep(stepInput as any); + } + if (actionType === "Database Query") { + const { databaseQueryStep } = await import("./steps/database-query"); + // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type + return await databaseQueryStep(stepInput as any); + } + if (actionType === "HTTP Request") { + const { httpRequestStep } = await import("./steps/http-request"); + // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type + return await httpRequestStep(stepInput as any); + } + if (actionType === "Condition") { + const { conditionStep } = await import("./steps/condition"); + // Special handling for condition: process templates and evaluate as JavaScript + // The condition field is kept as original template string for proper evaluation + const conditionExpression = stepInput.condition; + let evaluatedCondition: boolean; + + console.log("[Condition] Original expression:", conditionExpression); + + if (typeof conditionExpression === "boolean") { + evaluatedCondition = conditionExpression; + } else if (typeof conditionExpression === "string") { + try { + const evalContext: Record = {}; + let transformedExpression = conditionExpression; + const templatePattern = /\{\{@([^:]+):([^}]+)\}\}/g; + const varCounter = { value: 0 }; + + transformedExpression = transformedExpression.replace( + templatePattern, + (match, nodeId, rest) => + replaceTemplateVariable( + match, + nodeId, + rest, + evalContext, + varCounter + ) + ); - const varNames = Object.keys(evalContext); - const varValues = Object.values(evalContext); + const varNames = Object.keys(evalContext); + const varValues = Object.values(evalContext); - const evalFunc = new Function( - ...varNames, - `return (${transformedExpression});` - ); - const result = evalFunc(...varValues); - evaluatedCondition = Boolean(result); - } catch (error) { - console.error("[Condition] Failed to evaluate condition:", error); - console.error("[Condition] Expression was:", conditionExpression); - // If evaluation fails, treat as false to be safe - evaluatedCondition = false; - } - } else { - // Coerce to boolean for other types - evaluatedCondition = Boolean(conditionExpression); + const evalFunc = new Function( + ...varNames, + `return (${transformedExpression});` + ); + const result = evalFunc(...varValues); + evaluatedCondition = Boolean(result); + } catch (error) { + console.error("[Condition] Failed to evaluate condition:", error); + console.error("[Condition] Expression was:", conditionExpression); + // If evaluation fails, treat as false to be safe + evaluatedCondition = false; } - - console.log("[Condition] Final result:", evaluatedCondition); - - // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type - return await conditionStep({ condition: evaluatedCondition } as any); + } else { + // Coerce to boolean for other types + evaluatedCondition = Boolean(conditionExpression); } - // Fallback for unknown action types - return { - success: false, - error: `Unknown action type: ${actionType}`, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; + console.log("[Condition] Final result:", evaluatedCondition); + + // biome-ignore lint/suspicious/noExplicitAny: Dynamic step input type + return await conditionStep({ condition: evaluatedCondition } as any); } + + // Fallback for unknown action types + return { + success: false, + error: `Unknown action type: ${actionType}`, + }; } /** @@ -268,7 +260,6 @@ function processTemplates( /** * Main workflow executor function */ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Workflow execution requires complex orchestration export async function executeWorkflow(input: WorkflowExecutionInput) { "use workflow"; @@ -451,10 +442,28 @@ export async function executeWorkflow(input: WorkflowExecutionInput) { }); } else if (node.data.type === "action") { const config = node.data.config || {}; - const actionType = config.actionType as string; + const actionType = config.actionType as string | undefined; console.log("[Workflow Executor] Executing action node:", actionType); + // Check if action type is defined + if (!actionType) { + result = { + success: false, + error: `Action node "${node.data.label || node.id}" has no action type configured`, + }; + + await logNodeComplete({ + logId: logInfo.logId, + startTime: logInfo.startTime, + status: "error", + error: result.error, + }); + + results[nodeId] = result; + return; + } + // Process templates in config, but keep condition unprocessed for special handling const configWithoutCondition = { ...config }; const originalCondition = config.condition; @@ -489,17 +498,40 @@ export async function executeWorkflow(input: WorkflowExecutionInput) { resultType: typeof stepResult, }); - result = { - success: true, - data: stepResult, - }; - - await logNodeComplete({ - logId: logInfo.logId, - startTime: logInfo.startTime, - status: "success", - output: result.data, - }); + // Check if the step returned an error result + const isErrorResult = + stepResult && + typeof stepResult === "object" && + "success" in stepResult && + (stepResult as { success: boolean }).success === false; + + if (isErrorResult) { + const errorResult = stepResult as { success: false; error?: string }; + result = { + success: false, + error: errorResult.error || "Step execution failed", + }; + + await logNodeComplete({ + logId: logInfo.logId, + startTime: logInfo.startTime, + status: "error", + output: stepResult, + error: result.error, + }); + } else { + result = { + success: true, + data: stepResult, + }; + + await logNodeComplete({ + logId: logInfo.logId, + startTime: logInfo.startTime, + status: "success", + output: result.data, + }); + } } else { console.log("[Workflow Executor] Unknown node type:", node.data.type); result = { @@ -576,9 +608,10 @@ export async function executeWorkflow(input: WorkflowExecutionInput) { } } catch (error) { console.error("[Workflow Executor] Error executing node:", nodeId, error); + const errorMessage = await getErrorMessageAsync(error); const errorResult = { success: false, - error: error instanceof Error ? error.message : "Unknown error", + error: errorMessage, }; results[nodeId] = errorResult; @@ -641,6 +674,8 @@ export async function executeWorkflow(input: WorkflowExecutionInput) { error ); + const errorMessage = await getErrorMessageAsync(error); + // Update execution record with error if we have an executionId if (executionId) { try { @@ -649,7 +684,7 @@ export async function executeWorkflow(input: WorkflowExecutionInput) { action: "complete", executionId, status: "error", - error: error instanceof Error ? error.message : "Unknown error", + error: errorMessage, startTime: Date.now(), }); } catch (logError) { @@ -661,7 +696,7 @@ export async function executeWorkflow(input: WorkflowExecutionInput) { success: false, results, outputs, - error: error instanceof Error ? error.message : "Unknown error", + error: errorMessage, }; } } diff --git a/package.json b/package.json index c9b4b7dd5..e078cd6b8 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,14 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "@ai-sdk/provider": "^2.0.0", "@linear/sdk": "^63.2.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.15", "@slack/web-api": "^7.12.0", "@vercel/sdk": "^1.17.1", "@xyflow/react": "^12.9.2", - "ai": "^5.0.87", + "ai": "^5.0.102", "better-auth": "^1.3.34", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41341d43d..97ae46347 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ai-sdk/provider': + specifier: ^2.0.0 + version: 2.0.0 '@linear/sdk': specifier: ^63.2.0 version: 63.2.0 @@ -27,8 +30,8 @@ importers: specifier: ^12.9.2 version: 12.9.2(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) ai: - specifier: ^5.0.87 - version: 5.0.87(zod@4.1.12) + specifier: ^5.0.102 + version: 5.0.102(zod@4.1.12) better-auth: specifier: ^1.3.34 version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -138,14 +141,14 @@ importers: packages: - '@ai-sdk/gateway@2.0.6': - resolution: {integrity: sha512-FmhR6Tle09I/RUda8WSPpJ57mjPWzhiVVlB50D+k+Qf/PBW0CBtnbAUxlNSR5v+NIZNLTK3C56lhb23ntEdxhQ==} + '@ai-sdk/gateway@2.0.15': + resolution: {integrity: sha512-i1YVKzC1dg9LGvt+GthhD7NlRhz9J4+ZRj3KELU14IZ/MHPsOBiFeEoCCIDLR+3tqT8/+5nIsK3eZ7DFRfMfdw==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@3.0.16': - resolution: {integrity: sha512-lsWQY9aDXHitw7C1QRYIbVGmgwyT98TF3MfM8alNIXKpdJdi+W782Rzd9f1RyOfgRmZ08gJ2EYNDhWNK7RqpEA==} + '@ai-sdk/provider-utils@3.0.17': + resolution: {integrity: sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -2277,10 +2280,6 @@ packages: '@aws-sdk/credential-provider-web-identity': optional: true - '@vercel/oidc@3.0.3': - resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==} - engines: {node: '>= 20'} - '@vercel/oidc@3.0.5': resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} @@ -2380,8 +2379,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ai@5.0.87: - resolution: {integrity: sha512-9Cjx7o8IY9zAczigX0Tk/BaQwjPe/M6DpEjejKSBNrf8mOPIvyM+pJLqJSC10IsKci3FPsnaizJeJhoetU1Wfw==} + ai@5.0.102: + resolution: {integrity: sha512-snRK3nS5DESOjjpq7S74g8YszWVMzjagfHqlJWZsbtl9PyOS+2XUd8dt2wWg/jdaq/jh0aU66W1mx5qFjUQyEg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -4288,14 +4287,14 @@ packages: snapshots: - '@ai-sdk/gateway@2.0.6(zod@4.1.12)': + '@ai-sdk/gateway@2.0.15(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.16(zod@4.1.12) - '@vercel/oidc': 3.0.3 + '@ai-sdk/provider-utils': 3.0.17(zod@4.1.12) + '@vercel/oidc': 3.0.5 zod: 4.1.12 - '@ai-sdk/provider-utils@3.0.16(zod@4.1.12)': + '@ai-sdk/provider-utils@3.0.17(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 '@standard-schema/spec': 1.0.0 @@ -6719,8 +6718,6 @@ snapshots: optionalDependencies: '@aws-sdk/credential-provider-web-identity': 3.609.0(@aws-sdk/client-sts@3.936.0) - '@vercel/oidc@3.0.3': {} - '@vercel/oidc@3.0.5': {} '@vercel/queue@0.0.0-alpha.29': @@ -6954,11 +6951,11 @@ snapshots: acorn@8.15.0: {} - ai@5.0.87(zod@4.1.12): + ai@5.0.102(zod@4.1.12): dependencies: - '@ai-sdk/gateway': 2.0.6(zod@4.1.12) + '@ai-sdk/gateway': 2.0.15(zod@4.1.12) '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.16(zod@4.1.12) + '@ai-sdk/provider-utils': 3.0.17(zod@4.1.12) '@opentelemetry/api': 1.9.0 zod: 4.1.12