diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index ee4b7d8a01..d28d20aa6d 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1787,6 +1787,7 @@ enum FeatureFlagKey { IS_REST_METADATA_API_NEW_FORMAT_DIRECT IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED IS_SETTINGS_DISCOVERY_HERO_ENABLED + IS_WORKFLOW_RUN_STEP_LOGS_ENABLED } type WorkspaceUrls { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index dfb6062d4e..eb1c33c4bc 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1414,7 +1414,7 @@ export interface FeatureFlag { __typename: 'FeatureFlag' } -export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' +export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_WORKFLOW_RUN_STEP_LOGS_ENABLED' export interface WorkspaceUrls { customUrl?: Scalars['String'] @@ -8743,7 +8743,8 @@ export const enumFeatureFlagKey = { IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const, IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const, IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' as const, - IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const + IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const, + IS_WORKFLOW_RUN_STEP_LOGS_ENABLED: 'IS_WORKFLOW_RUN_STEP_LOGS_ENABLED' as const } export const enumIdentityProviderType = { diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index 9a2c9d520b..51ddbd712c 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -291,7 +291,8 @@ export enum FeatureFlagKey { IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', - IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED' + IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', + IS_WORKFLOW_RUN_STEP_LOGS_ENABLED = 'IS_WORKFLOW_RUN_STEP_LOGS_ENABLED' } export enum HealthIndicatorId { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 73a254a2be..98c9909fdf 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1643,7 +1643,8 @@ export enum FeatureFlagKey { IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', - IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED' + IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', + IS_WORKFLOW_RUN_STEP_LOGS_ENABLED = 'IS_WORKFLOW_RUN_STEP_LOGS_ENABLED' } export type Field = { diff --git a/packages/twenty-front/src/modules/ai/components/TerminalOutput.tsx b/packages/twenty-front/src/modules/ai/components/TerminalOutput.tsx index ae1504917a..c8fdd035ae 100644 --- a/packages/twenty-front/src/modules/ai/components/TerminalOutput.tsx +++ b/packages/twenty-front/src/modules/ai/components/TerminalOutput.tsx @@ -1,3 +1,4 @@ +import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { useState } from 'react'; @@ -70,7 +71,7 @@ const StyledOutputArea = styled.div<{ isError?: boolean }>` isError ? themeCssVariables.color.red : themeCssVariables.font.color.primary}; - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-family: ${MONOSPACE_FONT_FAMILY}; font-size: ${themeCssVariables.font.size.sm}; line-height: 1.5; max-height: 300px; diff --git a/packages/twenty-front/src/modules/side-panel/pages/workflow/step/view-run/components/SidePanelWorkflowRunViewStepContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/workflow/step/view-run/components/SidePanelWorkflowRunViewStepContent.tsx index 59f3d2fd7a..15af1a7881 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/workflow/step/view-run/components/SidePanelWorkflowRunViewStepContent.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/workflow/step/view-run/components/SidePanelWorkflowRunViewStepContent.tsx @@ -13,6 +13,7 @@ import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun'; import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow'; import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; +import { WorkflowRunStepLogsDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsDetail'; import { WorkflowRunStepInputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepInputDetail'; import { WorkflowRunStepNodeDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepNodeDetail'; import { WorkflowRunStepOutputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepOutputDetail'; @@ -22,11 +23,18 @@ import { } from '@/workflow/workflow-steps/types/WorkflowRunTabId'; import { getWorkflowRunStepExecutionStatus } from '@/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus'; import { WorkflowIteratorSubStepSwitcher } from '@/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowIteratorSubStepSwitcher'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { styled } from '@linaria/react'; -import { isNull } from '@sniptt/guards'; import { t } from '@lingui/core/macro'; +import { isNull } from '@sniptt/guards'; +import { FeatureFlagKey } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { IconLogin2, IconLogout, IconStepInto } from 'twenty-ui/display'; +import { + IconLogin2, + IconLogout, + IconStepInto, + IconTerminal, +} from 'twenty-ui/display'; import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledContainer = styled.div` @@ -51,6 +59,10 @@ export const SidePanelWorkflowRunViewStepContent = () => { const workflowRun = useWorkflowRun({ workflowRunId }); + const isStepLogsEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_WORKFLOW_RUN_STEP_LOGS_ENABLED, + ); + const sidePanelPageComponentInstance = useComponentInstanceStateContext( SidePanelPageComponentInstanceContext, ); @@ -113,6 +125,15 @@ export const SidePanelWorkflowRunViewStepContent = () => { Icon: IconLogin2, disabled: isInputTabDisabled, }, + ...(isStepLogsEnabled + ? [ + { + id: WorkflowRunTabId.LOGS, + title: t`Logs`, + Icon: IconTerminal, + } satisfies SingleTabProps, + ] + : []), ]; return ( @@ -162,6 +183,13 @@ export const SidePanelWorkflowRunViewStepContent = () => { /> ) : null} + {isStepLogsEnabled && activeTabId === WorkflowRunTabId.LOGS ? ( + + ) : null} + )} diff --git a/packages/twenty-front/src/modules/ui/theme/constants/MonospaceFontFamily.ts b/packages/twenty-front/src/modules/ui/theme/constants/MonospaceFontFamily.ts new file mode 100644 index 0000000000..202609b40e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/theme/constants/MonospaceFontFamily.ts @@ -0,0 +1 @@ +export const MONOSPACE_FONT_FAMILY = `'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace`; diff --git a/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRun.ts b/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRun.ts index a207632888..c8070db17f 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRun.ts +++ b/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRun.ts @@ -1,7 +1,8 @@ -import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { type WorkflowRun } from '@/workflow/types/Workflow'; import { useMemo } from 'react'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { workflowRunSchema } from 'twenty-shared/workflow'; @@ -10,9 +11,22 @@ export const useWorkflowRun = ({ }: { workflowRunId: string; }): WorkflowRun | undefined => { + const { recordGqlFields: defaultRecordGqlFields } = + useGenerateDepthRecordGqlFieldsFromObject({ + objectNameSingular: CoreObjectNameSingular.WorkflowRun, + depth: 1, + }); + + const recordGqlFields = useMemo(() => { + const { stepLogs: _omit, ...rest } = defaultRecordGqlFields; + + return rest; + }, [defaultRecordGqlFields]); + const { record: rawRecord } = useFindOneRecord({ objectNameSingular: CoreObjectNameSingular.WorkflowRun, objectRecordId: workflowRunId, + recordGqlFields, }); const { diff --git a/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRunStepLog.ts b/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRunStepLog.ts new file mode 100644 index 0000000000..4e819e6f35 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRunStepLog.ts @@ -0,0 +1,24 @@ +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { type WorkflowRun } from '@/workflow/types/Workflow'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; + +export const useWorkflowRunStepLog = ({ + workflowRunId, + stepId, + skip = false, +}: { + workflowRunId: string; + stepId: string; + skip?: boolean; +}): unknown | undefined => { + const { record } = useFindOneRecord< + Pick + >({ + objectNameSingular: CoreObjectNameSingular.WorkflowRun, + objectRecordId: workflowRunId, + recordGqlFields: { id: true, stepLogs: true }, + skip, + }); + + return record?.stepLogs?.[stepId]; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsAiAgentDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsAiAgentDetail.tsx new file mode 100644 index 0000000000..69dea6b017 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsAiAgentDetail.tsx @@ -0,0 +1,220 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { Fragment } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { type AiAgentStepLogDetails } from 'twenty-shared/workflow'; +import { + IconBrain, + IconClock, + IconCoins, + IconCpu, + IconTool, + IconWorld, +} from 'twenty-ui/display'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { formatDuration } from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters'; +import { + StyledBadgeGroup, + StyledEmptyHint, + StyledHeaderLeft, + StyledMetric, + StyledMetricLabel, + StyledMetricsRow, + StyledMetricValue, + StyledSection, + StyledSectionTitle, + StyledSummaryCard, + StyledSummaryHeader, + StyledTitle, +} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles'; +import { WorkflowRunStepLogsToolCallRow } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsToolCallRow'; +import { formatNumber } from '~/utils/format/formatNumber'; + +const StyledModelBadge = styled.span` + background: ${themeCssVariables.background.transparent.light}; + border-radius: ${themeCssVariables.border.radius.xs}; + color: ${themeCssVariables.font.color.tertiary}; + font-family: ${themeCssVariables.font.family}; + font-size: ${themeCssVariables.font.size.xs}; + padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]}; + white-space: nowrap; +`; + +const StyledUsageGrid = styled.div` + background: ${themeCssVariables.background.transparent.lighter}; + border: 1px solid ${themeCssVariables.border.color.light}; + border-radius: ${themeCssVariables.border.radius.sm}; + display: grid; + gap: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[4]}; + grid-template-columns: max-content 1fr; + padding: ${themeCssVariables.spacing[3]}; +`; + +const StyledUsageLabel = styled.span` + color: ${themeCssVariables.font.color.tertiary}; + font-size: ${themeCssVariables.font.size.sm}; +`; + +const StyledUsageValue = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-family: ${themeCssVariables.font.family}; + font-size: ${themeCssVariables.font.size.sm}; + font-variant-numeric: tabular-nums; + text-align: right; +`; + +const StyledToolList = styled.div` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[1]}; +`; + +const formatCost = (dollars: number): string => { + if (dollars === 0) { + return '$0'; + } + + if (Math.abs(dollars) < 0.01) { + return `$${dollars.toFixed(4)}`; + } + + return `$${formatNumber(dollars, { decimals: 2 })}`; +}; + +const formatTokenCount = (tokens: number): string => + formatNumber(tokens, { abbreviate: true, decimals: 1 }); + +export const WorkflowRunStepLogsAiAgentDetail = ({ + details, +}: { + details: AiAgentStepLogDetails; +}) => { + const { t } = useLingui(); + + const { + usage, + cost, + modelId, + toolCalls, + nativeWebSearchCallCount, + durationMs, + } = details; + + const usageRows = [ + { label: t`Input`, value: usage.inputTokens, show: true }, + { label: t`Output`, value: usage.outputTokens, show: true }, + { + label: t`Reasoning`, + value: usage.reasoningTokens, + show: isDefined(usage.reasoningTokens) && usage.reasoningTokens > 0, + }, + { + label: t`Cached read`, + value: usage.cacheReadTokens, + show: isDefined(usage.cacheReadTokens) && usage.cacheReadTokens > 0, + }, + { + label: t`Cached creation`, + value: usage.cacheCreationTokens, + show: + isDefined(usage.cacheCreationTokens) && usage.cacheCreationTokens > 0, + }, + ].filter((row) => row.show); + + return ( + <> + + + + + {t`AI agent run`} + + + {modelId} + + + + + + + + {t`Tokens`} + + + {formatTokenCount(usage.totalTokens)} + + + + + + {t`Cost`} + + + {formatCost(cost.totalCostInDollars)} + + + + + + {t`Tool calls`} + + {toolCalls.length} + + {nativeWebSearchCallCount > 0 && ( + + + + {t`Web searches`} + + {nativeWebSearchCallCount} + + )} + + + + {t`Duration`} + + {formatDuration(durationMs)} + + + + + {usageRows.length > 0 && ( + + {t`Token usage`} + + {usageRows.map((row) => ( + + {row.label} + + {formatNumber(row.value ?? 0)} + + + ))} + + + )} + + + + {toolCalls.length > 0 + ? t`Tool calls (${toolCalls.length})` + : t`Tool calls`} + + {toolCalls.length === 0 ? ( + {t`No tools were called`} + ) : ( + + {toolCalls.map((toolCall) => ( + + ))} + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsCodeDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsCodeDetail.tsx new file mode 100644 index 0000000000..0179547239 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsCodeDetail.tsx @@ -0,0 +1,111 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { type WorkflowRunStepLog } from 'twenty-shared/workflow'; +import { + IconAlertTriangle, + IconCheck, + IconClock, + IconTerminal, +} from 'twenty-ui/display'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily'; +import { formatDuration } from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters'; +import { + StyledErrorCard, + StyledErrorMessageText, + StyledHeaderLeft, + StyledMetric, + StyledMetricLabel, + StyledMetricsRow, + StyledMetricValue, + StyledSection, + StyledSectionTitle, + StyledStatusBadge, + StyledSummaryCard, + StyledSummaryHeader, + StyledTitle, +} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles'; + +const StyledErrorHeader = styled.div` + align-items: center; + color: ${themeCssVariables.color.red}; + display: flex; + font-weight: ${themeCssVariables.font.weight.semiBold}; + gap: ${themeCssVariables.spacing[1]}; +`; + +const StyledStackTrace = styled.pre` + background: ${themeCssVariables.background.tertiary}; + border-radius: ${themeCssVariables.border.radius.xs}; + color: ${themeCssVariables.font.color.tertiary}; + font-family: ${MONOSPACE_FONT_FAMILY}; + font-size: ${themeCssVariables.font.size.xs}; + margin: 0; + max-height: 240px; + overflow: auto; + padding: ${themeCssVariables.spacing[2]}; + white-space: pre-wrap; + word-break: break-word; +`; + +type CodeDetails = Extract; + +export const WorkflowRunStepLogsCodeDetail = ({ + details, +}: { + details: CodeDetails; +}) => { + const { t } = useLingui(); + + const isSuccess = details.status === 'SUCCESS'; + const StatusIcon = isSuccess ? IconCheck : IconAlertTriangle; + + return ( + <> + + + + + {t`Function run`} + + + + {isSuccess ? t`Success` : t`Error`} + + + + + + + {t`Duration`} + + + {formatDuration(details.durationMs)} + + + + + + {isDefined(details.error) && details.error !== null && ( + + {t`Error`} + + + + {details.error.type} + + + {details.error.message} + + {isDefined(details.error.stackTrace) && + details.error.stackTrace.length > 0 && ( + {details.error.stackTrace} + )} + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsDetail.tsx new file mode 100644 index 0000000000..d3f820e8e2 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsDetail.tsx @@ -0,0 +1,138 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { workflowRunStepLogSchema } from 'twenty-shared/workflow'; +import { IconInfoCircle } from 'twenty-ui/display'; +import { isTwoFirstDepths, JsonTree } from 'twenty-ui/json-visualizer'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { type JsonValue } from 'type-fest'; + +import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow'; +import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow'; +import { useWorkflowRunStepLog } from '@/workflow/hooks/useWorkflowRunStepLog'; +import { WorkflowRunStepLogsAiAgentDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsAiAgentDetail'; +import { WorkflowRunStepLogsCodeDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsCodeDetail'; +import { WorkflowRunStepLogsEmailDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsEmailDetail'; +import { WorkflowRunStepLogsEntries } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsEntries'; +import { WorkflowRunStepLogsHttpRequestDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsHttpRequestDetail'; +import { getIsDescendantOfIterator } from '@/workflow/workflow-steps/utils/getIsDescendantOfIterator'; +import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; + +const StyledRoot = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: ${themeCssVariables.spacing[4]}; + overflow: hidden scroll; + padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]}; +`; + +const StyledEmptyState = styled.div` + align-items: center; + color: ${themeCssVariables.font.color.tertiary}; + display: flex; + flex-direction: column; + font-size: ${themeCssVariables.font.size.sm}; + gap: ${themeCssVariables.spacing[2]}; + padding: ${themeCssVariables.spacing[8]} ${themeCssVariables.spacing[3]}; + text-align: center; +`; + +const StyledTruncatedNotice = styled.div` + background: ${themeCssVariables.background.transparent.orange}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.tertiary}; + font-size: ${themeCssVariables.font.size.xs}; + padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]}; +`; + +export const WorkflowRunStepLogsDetail = ({ stepId }: { stepId: string }) => { + const { t } = useLingui(); + const { copyToClipboard } = useCopyToClipboard(); + + const workflowRunId = useWorkflowRunIdOrThrow(); + const flow = useFlowOrThrow(); + + const rawStepLog = useWorkflowRunStepLog({ workflowRunId, stepId }); + + if (!isDefined(rawStepLog)) { + return ( + + + +
{t`No logs were recorded for this step.`}
+
+
+ ); + } + + const parseResult = workflowRunStepLogSchema.safeParse(rawStepLog); + + if (!parseResult.success) { + return ( + + + + ); + } + + const stepLog = parseResult.data; + + const isInsideIteratorLoop = isDefined(flow.steps) + ? getIsDescendantOfIterator({ stepId, steps: flow.steps }) + : false; + + const renderDetails = () => { + switch (stepLog.details.type) { + case 'AI_AGENT': + return ; + case 'CODE': + return ; + case 'HTTP_REQUEST': + return ( + + ); + case 'EMAIL': + return ; + default: + return ( + + ); + } + }; + + return ( + + {renderDetails()} + + + + {isDefined(stepLog.truncated) && ( + + {t`Some log data was dropped to fit the size limit (${stepLog.truncated.droppedEntries} entries, ${stepLog.truncated.droppedBytes} bytes).`} + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsEmailDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsEmailDetail.tsx new file mode 100644 index 0000000000..32e5f3f1c9 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsEmailDetail.tsx @@ -0,0 +1,211 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; +import { type WorkflowRunStepLog } from 'twenty-shared/workflow'; +import { + IconAlertTriangle, + IconCheck, + IconClock, + IconMail, + IconPaperclip, +} from 'twenty-ui/display'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { + formatBytes, + formatDuration, +} from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters'; +import { + StyledBadgeGroup, + StyledBodyMeta, + StyledEmptyHint, + StyledErrorCard, + StyledErrorMessageText, + StyledHeaderLeft, + StyledMetric, + StyledMetricLabel, + StyledMetricsRow, + StyledMetricValue, + StyledSection, + StyledSectionTitle, + StyledStatusBadge, + StyledSummaryCard, + StyledSummaryHeader, + StyledTitle, +} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles'; + +const StyledModeBadge = styled.span` + background: ${themeCssVariables.background.transparent.light}; + border-radius: ${themeCssVariables.border.radius.xs}; + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.xs}; + font-weight: ${themeCssVariables.font.weight.semiBold}; + padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]}; + text-transform: uppercase; +`; + +const StyledSubject = styled.div` + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.medium}; + overflow-wrap: anywhere; +`; + +const StyledRecipientsCard = styled.div` + background: ${themeCssVariables.background.transparent.lighter}; + border: 1px solid ${themeCssVariables.border.color.light}; + border-radius: ${themeCssVariables.border.radius.sm}; + display: grid; + gap: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[3]}; + grid-template-columns: max-content 1fr; + padding: ${themeCssVariables.spacing[3]}; +`; + +const StyledRecipientLabel = styled.span` + color: ${themeCssVariables.font.color.tertiary}; + font-size: ${themeCssVariables.font.size.sm}; + text-transform: uppercase; +`; + +const StyledRecipientValue = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.sm}; + overflow-wrap: anywhere; +`; + +const StyledBodyContainer = styled.div` + background: ${themeCssVariables.background.tertiary}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.sm}; + max-height: 300px; + overflow: auto; + padding: ${themeCssVariables.spacing[3]}; + word-break: break-word; +`; + +const StyledBodyPre = styled.pre` + margin: 0; + white-space: pre-wrap; + word-break: break-word; +`; + +type EmailDetails = Extract; + +export const WorkflowRunStepLogsEmailDetail = ({ + details, +}: { + details: EmailDetails; +}) => { + const { t } = useLingui(); + const isSuccess = details.status === 'SUCCESS'; + const StatusIcon = isSuccess ? IconCheck : IconAlertTriangle; + const titleText = details.mode === 'SEND' ? t`Send email` : t`Draft email`; + const statusLabel = isSuccess + ? details.mode === 'SEND' + ? t`Sent` + : t`Drafted` + : t`Failed`; + + return ( + <> + + + + + {titleText} + + + {details.mode} + + + {statusLabel} + + + + + {isDefined(details.subject) && details.subject.length > 0 && ( + {details.subject} + )} + + + + + + {t`Duration`} + + + {formatDuration(details.durationMs)} + + + {isDefined(details.attachmentCount) && + details.attachmentCount > 0 && ( + + + + {t`Attachments`} + + {details.attachmentCount} + + )} + + + + + {t`Recipients`} + + {t`To`} + + {isNonEmptyArray(details.recipients.to) + ? details.recipients.to.join(', ') + : t`—`} + + {isNonEmptyArray(details.recipients.cc) && ( + <> + {t`Cc`} + + {details.recipients.cc.join(', ')} + + + )} + {isNonEmptyArray(details.recipients.bcc) && ( + <> + {t`Bcc`} + + {details.recipients.bcc.join(', ')} + + + )} + + + + + {t`Body`} + {isDefined(details.bodyPreview) && details.bodyPreview.length > 0 ? ( + <> + + {details.bodyPreview} + + {(isDefined(details.bodyBytes) || details.bodyTruncated) && ( + + {isDefined(details.bodyBytes) && formatBytes(details.bodyBytes)} + {details.bodyTruncated && ' · truncated'} + + )} + + ) : ( + {t`No body`} + )} + + + {!isSuccess && isDefined(details.error) && ( + + {t`Error`} + + {details.error} + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsEntries.tsx b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsEntries.tsx new file mode 100644 index 0000000000..75dde21b1b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsEntries.tsx @@ -0,0 +1,113 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { type WorkflowRunStepLog } from 'twenty-shared/workflow'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily'; +import { + StyledSection, + StyledSectionTitle, +} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles'; + +const StyledEntriesList = styled.div` + background: ${themeCssVariables.background.tertiary}; + border-radius: ${themeCssVariables.border.radius.sm}; + display: flex; + flex-direction: column; + font-family: ${MONOSPACE_FONT_FAMILY}; + font-size: ${themeCssVariables.font.size.sm}; + max-height: 300px; + overflow-y: auto; + padding: ${themeCssVariables.spacing[2]}; +`; + +const StyledEntryRow = styled.div` + align-items: baseline; + color: ${themeCssVariables.font.color.primary}; + display: grid; + gap: ${themeCssVariables.spacing[2]}; + grid-template-columns: max-content max-content 1fr; + padding: ${themeCssVariables.spacing['0.5']} 0; +`; + +const StyledTimestamp = styled.span` + color: ${themeCssVariables.font.color.light}; + font-size: ${themeCssVariables.font.size.xs}; +`; + +const StyledLevelBadge = styled.span<{ level: string }>` + background: ${({ level }) => { + if (level === 'error') + return themeCssVariables.background.transparent.danger; + if (level === 'warn') + return themeCssVariables.background.transparent.orange; + if (level === 'info') return themeCssVariables.background.transparent.blue; + return themeCssVariables.background.transparent.light; + }}; + border-radius: ${themeCssVariables.border.radius.xs}; + color: ${({ level }) => { + if (level === 'error') return themeCssVariables.color.red; + if (level === 'warn') return themeCssVariables.color.orange; + if (level === 'info') return themeCssVariables.color.blue; + return themeCssVariables.font.color.tertiary; + }}; + font-size: ${themeCssVariables.font.size.xs}; + font-weight: ${themeCssVariables.font.weight.semiBold}; + padding: 0 ${themeCssVariables.spacing[1]}; + text-transform: uppercase; +`; + +const StyledMessage = styled.span` + overflow-wrap: anywhere; + white-space: pre-wrap; +`; + +const formatTimestamp = (timestamp: string): string => { + const date = new Date(timestamp); + + if (Number.isNaN(date.getTime())) { + return timestamp; + } + + return date.toLocaleTimeString(); +}; + +export const WorkflowRunStepLogsEntries = ({ + entries, + onlyLatestIteration = false, +}: { + entries: WorkflowRunStepLog['entries']; + // Set when the parent step lives inside an iterator loop: each iteration + // overwrites the same `stepLogs[stepId]` key, so what we render here is + // only the latest iteration's entries — not a cumulative view. + onlyLatestIteration?: boolean; +}) => { + const { t } = useLingui(); + + if (entries.length === 0) { + return null; + } + + const sectionTitle = onlyLatestIteration + ? t`Entries (${entries.length}, latest iteration only)` + : t`Entries (${entries.length})`; + + return ( + + {sectionTitle} + + {entries.map((entry, index) => ( + + + {formatTimestamp(entry.timestamp)} + + + {entry.level} + + {entry.message} + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsHttpRequestDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsHttpRequestDetail.tsx new file mode 100644 index 0000000000..8baaf1a090 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsHttpRequestDetail.tsx @@ -0,0 +1,255 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { Fragment } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { type WorkflowRunStepLog } from 'twenty-shared/workflow'; +import { + IconAlertTriangle, + IconArrowDown, + IconArrowUp, + IconClock, + IconWorld, +} from 'twenty-ui/display'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily'; +import { + formatBytes, + formatDuration, +} from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters'; +import { + StyledBadgeGroup, + StyledBodyMeta, + StyledEmptyHint, + StyledErrorCard, + StyledErrorMessageText, + StyledHeaderLeft, + StyledMetric, + StyledMetricLabel, + StyledMetricsRow, + StyledMetricValue, + StyledSection, + StyledSectionTitle, + StyledStatusBadge, + StyledSummaryCard, + StyledSummaryHeader, + StyledTitle, +} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles'; + +const StyledMethodBadge = styled.span<{ method: string }>` + background: ${themeCssVariables.background.transparent.light}; + border-radius: ${themeCssVariables.border.radius.xs}; + color: ${themeCssVariables.font.color.primary}; + font-family: ${themeCssVariables.font.family}; + font-size: ${themeCssVariables.font.size.xs}; + font-weight: ${themeCssVariables.font.weight.semiBold}; + padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]}; + text-transform: uppercase; +`; + +const StyledNetworkErrorBadge = styled.span` + align-items: center; + background: ${themeCssVariables.background.transparent.danger}; + border-radius: ${themeCssVariables.border.radius.xs}; + color: ${themeCssVariables.color.red}; + display: inline-flex; + font-size: ${themeCssVariables.font.size.xs}; + font-weight: ${themeCssVariables.font.weight.semiBold}; + gap: ${themeCssVariables.spacing[1]}; + padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]}; +`; + +const StyledUrl = styled.div` + color: ${themeCssVariables.font.color.tertiary}; + font-family: ${MONOSPACE_FONT_FAMILY}; + font-size: ${themeCssVariables.font.size.sm}; + overflow-wrap: anywhere; +`; + +const StyledHeaderTable = styled.div` + background: ${themeCssVariables.background.transparent.lighter}; + border: 1px solid ${themeCssVariables.border.color.light}; + border-radius: ${themeCssVariables.border.radius.sm}; + display: grid; + gap: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[3]}; + grid-template-columns: max-content 1fr; + padding: ${themeCssVariables.spacing[3]}; +`; + +const StyledHeaderName = styled.span` + color: ${themeCssVariables.font.color.tertiary}; + font-family: ${MONOSPACE_FONT_FAMILY}; + font-size: ${themeCssVariables.font.size.xs}; +`; + +const StyledHeaderValue = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-family: ${MONOSPACE_FONT_FAMILY}; + font-size: ${themeCssVariables.font.size.xs}; + overflow-wrap: anywhere; +`; + +const StyledBodyPre = styled.pre` + background: ${themeCssVariables.background.tertiary}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.primary}; + font-family: ${MONOSPACE_FONT_FAMILY}; + font-size: ${themeCssVariables.font.size.sm}; + margin: 0; + max-height: 300px; + overflow: auto; + padding: ${themeCssVariables.spacing[2]}; + white-space: pre-wrap; + word-break: break-word; +`; + +type HttpRequestDetails = Extract< + WorkflowRunStepLog['details'], + { type: 'HTTP_REQUEST' } +>; + +const HeaderTable = ({ headers }: { headers: Record }) => { + const entries = Object.entries(headers); + + if (entries.length === 0) { + return null; + } + + return ( + + {entries.map(([name, value]) => ( + + {name} + {value} + + ))} + + ); +}; + +const BodyBlock = ({ + body, + bodyBytes, + bodyTruncated, + emptyLabel, +}: { + body: string | undefined; + bodyBytes: number | undefined; + bodyTruncated: boolean | undefined; + emptyLabel: string; +}) => { + if (!isDefined(body) || body.length === 0) { + return {emptyLabel}; + } + + return ( + <> + {body} + {(isDefined(bodyBytes) || bodyTruncated) && ( + + {isDefined(bodyBytes) && formatBytes(bodyBytes)} + {bodyTruncated && ' · truncated'} + + )} + + ); +}; + +export const WorkflowRunStepLogsHttpRequestDetail = ({ + details, +}: { + details: HttpRequestDetails; +}) => { + const { t } = useLingui(); + const { request, response, durationMs, error } = details; + + const isSuccess = isDefined(response) + ? response.status >= 200 && response.status < 400 + : false; + const hasNetworkError = !isDefined(response); + + return ( + <> + + + + + {t`HTTP request`} + + + + {request.method} + + {isDefined(response) ? ( + + {response.status} + {isDefined(response.statusText) && + response.statusText.length > 0 + ? ` ${response.statusText}` + : ''} + + ) : ( + + + {t`Network error`} + + )} + + + {request.url} + + + + + {t`Duration`} + + {formatDuration(durationMs)} + + + + + + + + {t`Request`} + + + + + + {isDefined(response) ? ( + + + + {t`Response`} + + + + + ) : ( + hasNetworkError && + isDefined(error) && ( + + + + {t`Error`} + + + {error} + + + ) + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsToolCallRow.tsx b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsToolCallRow.tsx new file mode 100644 index 0000000000..36b9d72231 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsToolCallRow.tsx @@ -0,0 +1,243 @@ +import { styled } from '@linaria/react'; +import { useContext, useState } from 'react'; +import { type AiToolCallLog } from 'twenty-shared/workflow'; + +import { getToolDisplayMessage } from '@/ai/utils/getToolDisplayMessage'; +import { getToolIcon } from '@/ai/utils/getToolIcon'; +import { useLingui } from '@lingui/react/macro'; +import { type JsonValue } from 'type-fest'; +import { + IconCheck, + IconChevronDown, + IconChevronUp, + IconCircleX, +} from 'twenty-ui/display'; +import { JsonTree } from 'twenty-ui/json-visualizer'; +import { AnimatedExpandableContainer } from 'twenty-ui/layout'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; +import { isDefined } from 'twenty-shared/utils'; +import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + font-family: ${themeCssVariables.font.family}; + gap: ${themeCssVariables.spacing[2]}; +`; + +const StyledToggleButton = styled.button<{ isExpandable: boolean }>` + align-items: center; + background: none; + border: none; + color: ${themeCssVariables.font.color.tertiary}; + cursor: ${({ isExpandable }) => (isExpandable ? 'pointer' : 'default')}; + display: flex; + gap: ${themeCssVariables.spacing[1]}; + justify-content: space-between; + padding: ${themeCssVariables.spacing[1]} 0; + text-align: left; + transition: color calc(${themeCssVariables.animation.duration.fast} * 1s) + ease-in-out; + width: 100%; + + &:hover { + color: ${themeCssVariables.font.color.primary}; + } +`; + +const StyledLeftContent = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + min-width: 0; +`; + +const StyledRightContent = styled.div` + align-items: center; + display: flex; + flex-shrink: 0; + gap: ${themeCssVariables.spacing[2]}; +`; + +const StyledIconContainer = styled.div` + align-items: center; + display: flex; + + svg { + min-width: calc(${themeCssVariables.icon.size.sm} * 1px); + } +`; + +const StyledDisplayMessage = styled.span` + color: ${themeCssVariables.font.color.tertiary}; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.medium}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StyledToolBadge = styled.span` + background: ${themeCssVariables.background.transparent.light}; + border-radius: ${themeCssVariables.border.radius.xs}; + color: ${themeCssVariables.font.color.light}; + font-family: ${themeCssVariables.font.family}; + font-size: ${themeCssVariables.font.size.xs}; + padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]}; +`; + +const StyledContentContainer = styled.div` + background: ${themeCssVariables.background.transparent.lighter}; + border: 1px solid ${themeCssVariables.border.color.light}; + border-radius: ${themeCssVariables.border.radius.sm}; + min-width: 0; + padding: ${themeCssVariables.spacing[3]}; +`; + +const StyledTabContainer = styled.div` + border-bottom: 1px solid ${themeCssVariables.border.color.light}; + display: flex; + gap: ${themeCssVariables.spacing[3]}; + margin-bottom: ${themeCssVariables.spacing[3]}; +`; + +const StyledTab = styled.button<{ isActive: boolean }>` + background: none; + border: none; + color: ${({ isActive }) => + isActive + ? themeCssVariables.font.color.primary + : themeCssVariables.font.color.tertiary}; + cursor: pointer; + font-size: ${themeCssVariables.font.size.sm}; + font-weight: ${({ isActive }) => + isActive + ? themeCssVariables.font.weight.medium + : themeCssVariables.font.weight.regular}; + padding: 0 0 ${themeCssVariables.spacing[2]}; + + &:hover { + color: ${themeCssVariables.font.color.primary}; + } +`; + +const StyledJsonTreeContainer = styled.div` + overflow-x: auto; + + ul { + min-width: 0; + } +`; + +const StyledErrorMessage = styled.div` + color: ${themeCssVariables.color.red}; + font-size: ${themeCssVariables.font.size.sm}; + white-space: pre-wrap; + word-break: break-word; +`; + +type TabType = 'output' | 'input'; + +export const WorkflowRunStepLogsToolCallRow = ({ + toolCall, +}: { + toolCall: AiToolCallLog; +}) => { + const { theme } = useContext(ThemeContext); + const { t } = useLingui(); + const { copyToClipboard } = useCopyToClipboard(); + const [isExpanded, setIsExpanded] = useState(false); + const [activeTab, setActiveTab] = useState('output'); + + const hasError = toolCall.state === 'error'; + const hasOutput = isDefined(toolCall.output); + const hasInput = isDefined(toolCall.input); + const isExpandable = hasOutput || hasInput || hasError; + + const ToolIcon = getToolIcon(toolCall.toolName); + const StatusIcon = hasError ? IconCircleX : IconCheck; + const statusColor = hasError + ? themeCssVariables.color.red + : themeCssVariables.color.green; + + const displayMessage = getToolDisplayMessage( + toolCall.input ?? {}, + toolCall.toolName, + true, + ); + + return ( + + isExpandable && setIsExpanded(!isExpanded)} + > + + + + + {displayMessage} + + + {toolCall.toolName} + + + + {isExpandable && + (isExpanded ? ( + + ) : ( + + ))} + + + + {isExpandable && ( + + + {hasError && isDefined(toolCall.errorMessage) ? ( + {toolCall.errorMessage} + ) : ( + <> + + setActiveTab('output')} + > + {t`Output`} + + setActiveTab('input')} + > + {t`Input`} + + + + + false} + emptyArrayLabel={t`Empty Array`} + emptyObjectLabel={t`Empty Object`} + emptyStringLabel={t`[empty string]`} + arrowButtonCollapsedLabel={t`Expand`} + arrowButtonExpandedLabel={t`Collapse`} + onNodeValueClick={copyToClipboard} + /> + + + )} + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-run/observability/workflowRunStepLogsFormatters.ts b/packages/twenty-front/src/modules/workflow/workflow-run/observability/workflowRunStepLogsFormatters.ts new file mode 100644 index 0000000000..84d831a0c8 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-run/observability/workflowRunStepLogsFormatters.ts @@ -0,0 +1,19 @@ +export const formatDuration = (ms: number): string => { + if (ms < 1000) { + return `${ms}ms`; + } + + return `${(ms / 1000).toFixed(1)}s`; +}; + +export const formatBytes = (bytes: number): string => { + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-run/observability/workflowRunStepLogsStyles.ts b/packages/twenty-front/src/modules/workflow/workflow-run/observability/workflowRunStepLogsStyles.ts new file mode 100644 index 0000000000..edbbfe1b15 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-run/observability/workflowRunStepLogsStyles.ts @@ -0,0 +1,131 @@ +import { styled } from '@linaria/react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily'; + +export const StyledSummaryCard = styled.div` + background: ${themeCssVariables.background.transparent.lighter}; + border: 1px solid ${themeCssVariables.border.color.light}; + border-radius: ${themeCssVariables.border.radius.sm}; + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[3]}; + padding: ${themeCssVariables.spacing[3]}; +`; + +export const StyledSummaryHeader = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + justify-content: space-between; +`; + +export const StyledHeaderLeft = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + min-width: 0; +`; + +export const StyledTitle = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.medium}; +`; + +export const StyledBadgeGroup = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[1]}; +`; + +export const StyledStatusBadge = styled.span<{ isSuccess: boolean }>` + align-items: center; + background: ${({ isSuccess }) => + isSuccess + ? themeCssVariables.background.transparent.success + : themeCssVariables.background.transparent.danger}; + border-radius: ${themeCssVariables.border.radius.xs}; + color: ${({ isSuccess }) => + isSuccess ? themeCssVariables.color.green : themeCssVariables.color.red}; + display: inline-flex; + font-size: ${themeCssVariables.font.size.xs}; + font-weight: ${themeCssVariables.font.weight.semiBold}; + gap: ${themeCssVariables.spacing[1]}; + padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]}; +`; + +export const StyledMetricsRow = styled.div` + align-items: stretch; + display: grid; + gap: ${themeCssVariables.spacing[2]}; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); +`; + +export const StyledMetric = styled.div` + align-items: flex-start; + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[1]}; +`; + +export const StyledMetricLabel = styled.div` + align-items: center; + color: ${themeCssVariables.font.color.tertiary}; + display: flex; + font-size: ${themeCssVariables.font.size.xs}; + gap: ${themeCssVariables.spacing[1]}; + text-transform: uppercase; +`; + +export const StyledMetricValue = styled.div` + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.lg}; + font-weight: ${themeCssVariables.font.weight.semiBold}; +`; + +export const StyledSection = styled.section` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[2]}; +`; + +export const StyledSectionTitle = styled.h3` + align-items: center; + color: ${themeCssVariables.font.color.tertiary}; + display: flex; + font-size: ${themeCssVariables.font.size.sm}; + font-weight: ${themeCssVariables.font.weight.medium}; + gap: ${themeCssVariables.spacing[1]}; + margin: 0; + text-transform: uppercase; +`; + +export const StyledErrorCard = styled.div` + background: ${themeCssVariables.background.transparent.danger}; + border: 1px solid ${themeCssVariables.color.red}; + border-radius: ${themeCssVariables.border.radius.sm}; + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[2]}; + padding: ${themeCssVariables.spacing[3]}; +`; + +export const StyledErrorMessageText = styled.div` + color: ${themeCssVariables.font.color.primary}; + font-family: ${MONOSPACE_FONT_FAMILY}; + font-size: ${themeCssVariables.font.size.sm}; + white-space: pre-wrap; + word-break: break-word; +`; + +export const StyledEmptyHint = styled.div` + color: ${themeCssVariables.font.color.light}; + font-size: ${themeCssVariables.font.size.sm}; + font-style: italic; +`; + +export const StyledBodyMeta = styled.div` + color: ${themeCssVariables.font.color.light}; + font-size: ${themeCssVariables.font.size.xs}; +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/types/WorkflowRunTabId.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/types/WorkflowRunTabId.tsx index a5c7edf749..8286d21d09 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/types/WorkflowRunTabId.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/types/WorkflowRunTabId.tsx @@ -1,7 +1,8 @@ -export type WorkflowRunTabIdType = 'node' | 'input' | 'output'; +export type WorkflowRunTabIdType = 'node' | 'input' | 'output' | 'logs'; export enum WorkflowRunTabId { NODE = 'node', INPUT = 'input', OUTPUT = 'output', + LOGS = 'logs', } diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module.ts index be55802d8f..57165daa1f 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module.ts @@ -2,9 +2,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; -import { BackfillFieldsWidgetNewFieldDefaultVisibilityCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000030000-backfill-fields-widget-new-field-default-visibility.command'; import { MigrateAiModelPreferencesCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000000000-migrate-ai-model-preferences.command'; +import { BackfillFieldsWidgetNewFieldDefaultVisibilityCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000030000-backfill-fields-widget-new-field-default-visibility.command'; +import { AddWorkflowRunStepLogsFieldCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1800000000000-add-workflow-run-step-logs-field.command'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; +import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @@ -13,10 +16,14 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace TypeOrmModule.forFeature([KeyValuePairEntity]), WorkspaceCacheModule, WorkspaceIteratorModule, + ApplicationModule, + FieldMetadataModule, + WorkspaceCacheModule, WorkspaceMigrationModule, ], providers: [ MigrateAiModelPreferencesCommand, + AddWorkflowRunStepLogsFieldCommand, BackfillFieldsWidgetNewFieldDefaultVisibilityCommand, ], }) diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1800000000000-add-workflow-run-step-logs-field.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1800000000000-add-workflow-run-step-logs-field.command.ts new file mode 100644 index 0000000000..89dc2541af --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1800000000000-add-workflow-run-step-logs-field.command.ts @@ -0,0 +1,129 @@ +import { Command } from 'nest-commander'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +const WORKFLOW_RUN_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.workflowRun.universalIdentifier; + +const STEP_LOGS_FIELD_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.workflowRun.fields.stepLogs.universalIdentifier; + +@RegisteredWorkspaceCommand('2.9.0', 1800000000000) +@Command({ + name: 'upgrade:2-9:add-workflow-run-step-logs-field', + description: + 'Add stepLogs JSONB field to the workflowRun standard object for existing workspaces. Per-step observability payload (token usage, tool calls, log entries) is written here.', +}) +export class AddWorkflowRunStepLogsFieldCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly fieldMetadataService: FieldMetadataService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + ]); + + const workflowRunObject = + findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatObjectMetadataMaps, + universalIdentifier: WORKFLOW_RUN_UNIVERSAL_IDENTIFIER, + }); + + if (!isDefined(workflowRunObject)) { + this.logger.log( + `workflowRun object not found for workspace ${workspaceId}, skipping`, + ); + + return; + } + + const existingStepLogsField = + findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatFieldMetadataMaps, + universalIdentifier: STEP_LOGS_FIELD_UNIVERSAL_IDENTIFIER, + }); + + if (isDefined(existingStepLogsField)) { + this.logger.log( + `stepLogs field already present on workflowRun for workspace ${workspaceId}, skipping`, + ); + + return; + } + + const createFieldInput: Omit = { + objectMetadataId: workflowRunObject.id, + name: 'stepLogs', + type: FieldMetadataType.RAW_JSON, + label: 'Step logs', + description: + 'Per-step observability payload (token usage, tool calls, log entries)', + icon: 'IconTerminal2', + isNullable: true, + isUIReadOnly: true, + isCustom: false, + isSystem: true, + isActive: true, + universalIdentifier: STEP_LOGS_FIELD_UNIVERSAL_IDENTIFIER, + }; + + if (isDryRun) { + this.logger.log( + `[DRY RUN] Would create stepLogs field on workflowRun for workspace ${workspaceId}`, + ); + + return; + } + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + try { + await this.fieldMetadataService.createManyFields({ + createFieldInputs: [createFieldInput], + workspaceId, + ownerFlatApplication: twentyStandardFlatApplication, + isSystemBuild: true, + }); + } catch (error) { + this.logger.error( + `Failed to add stepLogs field on workflowRun for workspace ${workspaceId}:\n${ + error instanceof Error ? error.stack : JSON.stringify(error, null, 2) + }`, + ); + throw error; + } + + this.logger.log( + `Added stepLogs field on workflowRun for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application-logs/utils/parse-application-log-lines.spec.ts b/packages/twenty-server/src/engine/core-modules/application-logs/utils/parse-application-log-lines.spec.ts index c44bc175a5..8e94fec61e 100644 --- a/packages/twenty-server/src/engine/core-modules/application-logs/utils/parse-application-log-lines.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/application-logs/utils/parse-application-log-lines.spec.ts @@ -122,4 +122,24 @@ describe('parseApplicationLogLines', () => { }, ]); }); + + it('strips ANSI color escapes from structured messages (chalk-style)', () => { + const raw = '2024-01-01T00:00:00.000Z INFO \u001B[33m4 \u001B[39m'; + + expect(parseApplicationLogLines(raw)).toEqual([ + { + timestamp: new Date('2024-01-01T00:00:00.000Z'), + level: 'INFO', + message: '4 ', + }, + ]); + }); + + it('strips ANSI color escapes from unstructured lines too', () => { + const raw = '\u001B[1;31mfatal\u001B[0m something bad'; + const result = parseApplicationLogLines(raw); + + expect(result).toHaveLength(1); + expect(result[0].message).toBe('fatal something bad'); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/application-logs/utils/parse-application-log-lines.ts b/packages/twenty-server/src/engine/core-modules/application-logs/utils/parse-application-log-lines.ts index 65120f4165..b00f5e45c4 100644 --- a/packages/twenty-server/src/engine/core-modules/application-logs/utils/parse-application-log-lines.ts +++ b/packages/twenty-server/src/engine/core-modules/application-logs/utils/parse-application-log-lines.ts @@ -1,4 +1,5 @@ import { type ParsedLogLine } from 'src/engine/core-modules/application-logs/types/parsed-log-line.type'; +import { stripAnsiEscapes } from 'src/engine/core-modules/application-logs/utils/strip-ansi-escapes.util'; // Matches: 2024-01-01T00:00:00.000Z INFO some message const LOG_LINE_REGEX = @@ -18,14 +19,14 @@ export const parseApplicationLogLines = (rawLogs: string): ParsedLogLine[] => { return { timestamp: new Date(match[1]), level: match[2], - message: match[3], + message: stripAnsiEscapes(match[3]), }; } return { timestamp: new Date(), level: 'INFO', - message: line, + message: stripAnsiEscapes(line), }; }); }; diff --git a/packages/twenty-server/src/engine/core-modules/application-logs/utils/strip-ansi-escapes.util.spec.ts b/packages/twenty-server/src/engine/core-modules/application-logs/utils/strip-ansi-escapes.util.spec.ts new file mode 100644 index 0000000000..5c4fc2c936 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application-logs/utils/strip-ansi-escapes.util.spec.ts @@ -0,0 +1,48 @@ +import { stripAnsiEscapes } from './strip-ansi-escapes.util'; + +describe('stripAnsiEscapes', () => { + it('returns plain ASCII strings unchanged', () => { + expect(stripAnsiEscapes('hello world')).toBe('hello world'); + }); + + it('strips SGR color codes from a yellow value', () => { + // What `console.log(chalk.yellow('4 '))` emits. + expect(stripAnsiEscapes('\u001B[33m4 \u001B[39m')).toBe('4 '); + }); + + it('strips compound SGR codes (bold + red, then reset)', () => { + expect(stripAnsiEscapes('\u001B[1;31merror\u001B[0m')).toBe('error'); + }); + + it('strips 256-color and truecolor SGR sequences', () => { + expect(stripAnsiEscapes('\u001B[38;5;208mwarn\u001B[39m')).toBe('warn'); + expect(stripAnsiEscapes('\u001B[38;2;0;128;255mblue\u001B[0m')).toBe( + 'blue', + ); + }); + + it('strips cursor-movement CSI sequences', () => { + expect(stripAnsiEscapes('a\u001B[2Jb\u001B[Hc')).toBe('abc'); + }); + + it('strips OSC sequences (e.g. terminal hyperlinks)', () => { + const link = + '\u001B]8;;https://twenty.com\u0007Twenty\u001B]8;;\u0007 rocks'; + + expect(stripAnsiEscapes(link)).toBe('Twenty rocks'); + }); + + it('handles mixed colored output across multiple chunks', () => { + const raw = + '\u001B[32mOK\u001B[39m \u001B[2mready\u001B[22m: \u001B[1mdone\u001B[0m'; + + expect(stripAnsiEscapes(raw)).toBe('OK ready: done'); + }); + + it('leaves untouched the bracket text that survived a missing ESC', () => { + // Defensive: if the ESC byte was already stripped upstream, we should not + // try to "fix" the bracketed remnants (we cannot tell them apart from real + // user text). + expect(stripAnsiEscapes('[33m4 [39m')).toBe('[33m4 [39m'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application-logs/utils/strip-ansi-escapes.util.ts b/packages/twenty-server/src/engine/core-modules/application-logs/utils/strip-ansi-escapes.util.ts new file mode 100644 index 0000000000..546ad5e459 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application-logs/utils/strip-ansi-escapes.util.ts @@ -0,0 +1,6 @@ +const ANSI_CSI_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g; + +const ANSI_OSC_REGEX = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g; + +export const stripAnsiEscapes = (value: string): string => + value.replace(ANSI_CSI_REGEX, '').replace(ANSI_OSC_REGEX, ''); diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts index 72a1f4380f..451ee4e1e7 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts @@ -28,6 +28,14 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [ 'Show the per-page hero illustration + video walkthrough modal on settings pages', }, }, + { + key: FeatureFlagKey.IS_WORKFLOW_RUN_STEP_LOGS_ENABLED, + metadata: { + label: 'Workflow Run Step Logs', + description: + 'Persist and display per-step observability logs (token usage, tool calls, HTTP bodies, serverless function output) on workflow runs', + }, + }, ...(process.env.CLOUDFLARE_API_KEY ? [ // { diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts index e2585aafad..3de54fa4fa 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts @@ -57,6 +57,8 @@ export class DraftEmailTool implements Tool { ccRecipients: data.recipients.cc, bccRecipients: data.recipients.bcc, subject: data.sanitizedSubject, + sanitizedHtmlBody: data.sanitizedHtmlBody, + plainTextBody: data.plainTextBody, connectedAccountId: data.connectedAccount.id, attachmentCount: data.attachments.length, }, diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts index b698e8a950..6670d8efb4 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts @@ -64,6 +64,8 @@ export class SendEmailTool implements Tool { ccRecipients: data.recipients.cc, bccRecipients: data.recipients.bcc, subject: data.sanitizedSubject, + sanitizedHtmlBody: data.sanitizedHtmlBody, + plainTextBody: data.plainTextBody, connectedAccountId: data.connectedAccount.id, attachmentCount: data.attachments.length, }, diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts index c3f2db5b81..4e0794fbad 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts @@ -70,6 +70,12 @@ export class HttpTool implements Tool { success: false, message: `HTTP ${method} request to ${url} failed`, error: error.response?.data || error.message || 'HTTP request failed', + status: error.response?.status, + statusText: error.response?.statusText, + headers: error.response?.headers as + | Record + | undefined, + result: error.response?.data, }; } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts index 2e72e3fd12..2aa9aadc15 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts @@ -1,12 +1,14 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { generateText } from 'ai'; import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service'; import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS } from 'src/engine/metadata-modules/ai/ai-billing/constants/native-web-search-cost-per-call-dollars'; import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; @@ -33,10 +35,20 @@ jest.mock('ai', () => ({ }), })); +const generateTextMock = generateText as jest.MockedFunction< + typeof generateText +>; + describe('AgentAsyncExecutorService — workflow agent role-scoped tool resolution', () => { let service: AgentAsyncExecutorService; let toolRegistry: { getToolsByCategories: jest.Mock }; let roleTargetRepository: { findOne: jest.Mock }; + let aiBillingService: { + decrementAndCheckAvailableCredits: jest.Mock; + calculateCost: jest.Mock; + emitAiTokenUsageEvent: jest.Mock; + billNativeWebSearchUsage: jest.Mock; + }; const agentId = 'agent-1'; const workspaceId = 'workspace-1'; @@ -54,6 +66,16 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti beforeEach(async () => { toolRegistry = { getToolsByCategories: jest.fn().mockResolvedValue({}) }; roleTargetRepository = { findOne: jest.fn() }; + aiBillingService = { + decrementAndCheckAvailableCredits: jest + .fn() + .mockResolvedValue({ hasNoMoreAvailableCredits: false }), + calculateCost: jest.fn().mockReturnValue(0), + emitAiTokenUsageEvent: jest.fn(), + billNativeWebSearchUsage: jest.fn(), + }; + + generateTextMock.mockClear(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -82,17 +104,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti bind: jest.fn().mockReturnValue({}), }, }, - { - provide: AiBillingService, - useValue: { - decrementAndCheckAvailableCredits: jest - .fn() - .mockResolvedValue({ hasNoMoreAvailableCredits: false }), - calculateCost: jest.fn().mockReturnValue(0), - emitAiTokenUsageEvent: jest.fn(), - billNativeWebSearchUsage: jest.fn(), - }, - }, + { provide: AiBillingService, useValue: aiBillingService }, { provide: BillingUsageService, useValue: { @@ -144,4 +156,79 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti expect(toolRegistry.getToolsByCategories).not.toHaveBeenCalled(); }); + + describe('cost folding', () => { + const baseUsage = { + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + inputTokenDetails: { + noCacheTokens: 100, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + outputTokenDetails: { textTokens: 50, reasoningTokens: 0 }, + }; + + it('returns token cost only when no native web searches happened', async () => { + roleTargetRepository.findOne.mockResolvedValueOnce({ + roleId: agentRoleId, + }); + aiBillingService.calculateCost.mockReturnValue(0.0042); + generateTextMock.mockResolvedValueOnce({ + text: '', + steps: [{ toolCalls: [] }], + usage: baseUsage, + } as unknown as Awaited>); + + const result = await service.executeAgent({ + agent: buildAgent(), + userPrompt: 'test', + workspaceId, + }); + + expect(result.nativeWebSearchCallCount).toBe(0); + expect(result.totalCostInDollars).toBeCloseTo(0.0042, 6); + // credits = dollars * 1_000_000 + expect(result.creditsUsedMicro).toBe(4200); + }); + + it('folds native web search dollars into totalCostInDollars and creditsUsedMicro', async () => { + roleTargetRepository.findOne.mockResolvedValueOnce({ + roleId: agentRoleId, + }); + aiBillingService.calculateCost.mockReturnValue(0.01); + generateTextMock.mockResolvedValueOnce({ + text: '', + steps: [ + { + toolCalls: [ + { toolName: 'web_search' }, + { toolName: 'web_search' }, + { toolName: 'some_other_tool' }, + ], + }, + { toolCalls: [{ toolName: 'web_search' }] }, + ], + usage: baseUsage, + } as unknown as Awaited>); + + const result = await service.executeAgent({ + agent: buildAgent(), + userPrompt: 'test', + workspaceId, + }); + + const expectedSearchCost = 3 * NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS; + + expect(result.nativeWebSearchCallCount).toBe(3); + expect(result.totalCostInDollars).toBeCloseTo( + 0.01 + expectedSearchCost, + 6, + ); + expect(result.creditsUsedMicro).toBe( + Math.round((0.01 + expectedSearchCost) * 1_000_000), + ); + }); + }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts index 3d5a9d99c9..d575df992c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts @@ -7,6 +7,7 @@ import { type LanguageModelUsage, Output, stepCountIs, + type StepResult, type ToolSet, } from 'ai'; import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants'; @@ -27,6 +28,7 @@ import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/ import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const'; import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; +import { NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS } from 'src/engine/metadata-modules/ai/ai-billing/constants/native-web-search-cost-per-call-dollars'; import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util'; import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util'; @@ -120,6 +122,7 @@ export class AgentAsyncExecutorService { let accumulatedUsage: LanguageModelUsage = EMPTY_USAGE; let cacheCreationTokens = 0; let nativeWebSearchCallCount = 0; + let executionSteps: StepResult[] = []; try { if (agent) { @@ -257,69 +260,83 @@ export class AgentAsyncExecutorService { nativeWebSearchCallCount = countNativeWebSearchCallsFromSteps( textResponse.steps, ); + executionSteps = textResponse.steps; const agentSchema = agent?.responseFormat?.type === 'json' ? agent.responseFormat.schema : undefined; - if (!agentSchema) { - return { - result: { response: textResponse.text }, - usage: textResponse.usage, - cacheCreationTokens, - nativeWebSearchCallCount, - hasNoMoreAvailableCredits, - }; - } + let result: object = { response: textResponse.text }; - const structuredResult = await generateText({ - system: WORKFLOW_SYSTEM_PROMPTS.OUTPUT_GENERATOR, - model: registeredModel.model, - prompt: `Based on the following execution results, generate the structured output according to the schema: + if (agentSchema) { + const structuredResult = await generateText({ + system: WORKFLOW_SYSTEM_PROMPTS.OUTPUT_GENERATOR, + model: registeredModel.model, + prompt: `Based on the following execution results, generate the structured output according to the schema: Execution Results: ${textResponse.text} Please generate the structured output based on the execution results and context above.`, - output: Output.object({ schema: jsonSchema(agentSchema) }), - experimental_telemetry: AI_TELEMETRY_CONFIG, - onStepFinish: async (step) => { - const { hasNoMoreAvailableCredits: stepHasNoMoreAvailableCredits } = - await this.aiBillingService.decrementAndCheckAvailableCredits( - registeredModel.modelId, - { - usage: step.usage, - cacheCreationTokens: extractCacheCreationTokens( - step.providerMetadata, - ), - }, - workspaceId, - ); + output: Output.object({ schema: jsonSchema(agentSchema) }), + experimental_telemetry: AI_TELEMETRY_CONFIG, + onStepFinish: async (step) => { + const { hasNoMoreAvailableCredits: stepHasNoMoreAvailableCredits } = + await this.aiBillingService.decrementAndCheckAvailableCredits( + registeredModel.modelId, + { + usage: step.usage, + cacheCreationTokens: extractCacheCreationTokens( + step.providerMetadata, + ), + }, + workspaceId, + ); - if (stepHasNoMoreAvailableCredits) { - hasNoMoreAvailableCredits = true; - } - }, - }); + if (stepHasNoMoreAvailableCredits) { + hasNoMoreAvailableCredits = true; + } + }, + }); - accumulatedUsage = mergeLanguageModelUsage( - textResponse.usage, - structuredResult.usage, - ); - - if (structuredResult.output == null) { - throw new AiException( - 'Failed to generate structured output from execution results', - AiExceptionCode.AGENT_EXECUTION_FAILED, + accumulatedUsage = mergeLanguageModelUsage( + textResponse.usage, + structuredResult.usage, ); + executionSteps = [...textResponse.steps, ...structuredResult.steps]; + + if (structuredResult.output == null) { + throw new AiException( + 'Failed to generate structured output from execution results', + AiExceptionCode.AGENT_EXECUTION_FAILED, + ); + } + + result = structuredResult.output as object; } + const resolvedModelId = registeredModel.modelId; + const tokenCostInDollars = this.aiBillingService.calculateCost( + resolvedModelId, + { usage: accumulatedUsage, cacheCreationTokens }, + ); + const totalCostInDollars = + tokenCostInDollars + + nativeWebSearchCallCount * NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS; + const creditsUsedMicro = Math.round( + convertDollarsToBillingCredits(totalCostInDollars), + ); + return { - result: structuredResult.output as object, + result, usage: accumulatedUsage, cacheCreationTokens, nativeWebSearchCallCount, hasNoMoreAvailableCredits, + steps: executionSteps, + modelId: resolvedModelId, + totalCostInDollars, + creditsUsedMicro, }; } catch (error) { if (error instanceof AiException) { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts index 8b9733c8a6..6397b5825d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts @@ -1,4 +1,4 @@ -import { type LanguageModelUsage } from 'ai'; +import { type LanguageModelUsage, type StepResult, type ToolSet } from 'ai'; export interface AgentExecutionResult { result: object; @@ -6,4 +6,8 @@ export interface AgentExecutionResult { cacheCreationTokens: number; nativeWebSearchCallCount: number; hasNoMoreAvailableCredits: boolean; + steps?: StepResult[]; + modelId?: string; + totalCostInDollars?: number; + creditsUsedMicro?: number; } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/map-ai-steps-to-tool-call-logs.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/map-ai-steps-to-tool-call-logs.util.spec.ts new file mode 100644 index 0000000000..20f39bfc30 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/map-ai-steps-to-tool-call-logs.util.spec.ts @@ -0,0 +1,226 @@ +import { type StepResult, type ToolSet } from 'ai'; + +import { mapAiStepsToToolCallLogs } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/map-ai-steps-to-tool-call-logs.util'; + +type StepContentPart = StepResult['content'][number]; + +const buildStep = (content: StepContentPart[]): StepResult => + ({ content }) as unknown as StepResult; + +describe('mapAiStepsToToolCallLogs', () => { + it('returns an empty array when there are no steps', () => { + expect(mapAiStepsToToolCallLogs([])).toEqual([]); + }); + + it('pairs a tool-call with its tool-result into a single success entry', () => { + const steps = [ + buildStep([ + { + type: 'tool-call', + toolName: 'findRecords', + toolCallId: 'call_1', + input: { limit: 10 }, + } as StepContentPart, + { + type: 'tool-result', + toolName: 'findRecords', + toolCallId: 'call_1', + input: { limit: 10 }, + output: { totalCount: 2 }, + } as StepContentPart, + ]), + ]; + + const result = mapAiStepsToToolCallLogs(steps); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + toolName: 'findRecords', + toolCallId: 'call_1', + state: 'success', + output: { totalCount: 2 }, + }); + }); + + it('marks a tool-call followed by tool-error as error and records the message', () => { + const steps = [ + buildStep([ + { + type: 'tool-call', + toolName: 'createNote', + toolCallId: 'call_2', + input: { title: 'x' }, + } as StepContentPart, + { + type: 'tool-error', + toolName: 'createNote', + toolCallId: 'call_2', + input: { title: 'x' }, + error: new Error('Validation failed'), + } as StepContentPart, + ]), + ]; + + const result = mapAiStepsToToolCallLogs(steps); + + expect(result).toHaveLength(1); + expect(result[0].state).toBe('error'); + expect(result[0].errorMessage).toContain('Validation failed'); + }); + + it('truncates oversized tool input and output', () => { + const longString = 'x'.repeat(50_000); + + const steps = [ + buildStep([ + { + type: 'tool-call', + toolName: 'fetchUrl', + toolCallId: 'call_3', + input: { html: longString }, + } as StepContentPart, + { + type: 'tool-result', + toolName: 'fetchUrl', + toolCallId: 'call_3', + input: { html: longString }, + output: { body: longString }, + } as StepContentPart, + ]), + ]; + + const result = mapAiStepsToToolCallLogs(steps, { + maxToolInputBytes: 100, + maxToolOutputBytes: 100, + }); + + const serializedInput = JSON.stringify(result[0].input); + const serializedOutput = JSON.stringify(result[0].output); + + expect(serializedInput.length).toBeLessThan(200); + expect(serializedInput).toContain('truncated'); + expect(serializedOutput.length).toBeLessThan(200); + expect(serializedOutput).toContain('truncated'); + }); + + it('stops collecting tool calls past the per-step cap', () => { + const content: StepContentPart[] = []; + + for (let i = 0; i < 10; i++) { + content.push({ + type: 'tool-call', + toolName: 'noop', + toolCallId: `call_${i}`, + input: {}, + } as StepContentPart); + } + + const steps = [buildStep(content)]; + + const result = mapAiStepsToToolCallLogs(steps, { + maxToolCallsPerStep: 3, + }); + + expect(result).toHaveLength(3); + }); + + it('preserves all web_search sources in tool output', () => { + const manySources = Array.from({ length: 25 }, (_, index) => ({ + url: `https://example.com/${index}`, + type: 'url', + })); + + const steps = [ + buildStep([ + { + type: 'tool-call', + toolName: 'web_search', + toolCallId: 'call_search', + input: {}, + } as StepContentPart, + { + type: 'tool-result', + toolName: 'web_search', + toolCallId: 'call_search', + input: {}, + output: { + action: { type: 'search', query: 'twenty crm' }, + sources: manySources, + }, + } as StepContentPart, + ]), + ]; + + const result = mapAiStepsToToolCallLogs(steps); + const output = result[0].output as { + sources: unknown[]; + sourcesDroppedCount?: number; + }; + + expect(output.sources).toHaveLength(25); + expect(output.sourcesDroppedCount).toBeUndefined(); + }); + + it('strips searchVector from nested record outputs', () => { + const steps = [ + buildStep([ + { + type: 'tool-call', + toolName: 'find_companies', + toolCallId: 'call_find', + input: {}, + } as StepContentPart, + { + type: 'tool-result', + toolName: 'find_companies', + toolCallId: 'call_find', + input: {}, + output: { + result: { + count: '1', + records: [ + { + id: 'abc', + name: 'Apple', + searchVector: "'apple':1 'inc':2", + }, + ], + }, + }, + } as StepContentPart, + ]), + ]; + + const result = mapAiStepsToToolCallLogs(steps); + const output = result[0].output as { + result: { records: Array> }; + }; + + expect(output.result.records[0]).not.toHaveProperty('searchVector'); + expect(output.result.records[0].name).toBe('Apple'); + }); + + it('ignores text / reasoning / source parts', () => { + const steps = [ + buildStep([ + { type: 'text', text: 'hello' } as StepContentPart, + { + type: 'reasoning', + text: 'thinking…', + state: 'done', + } as StepContentPart, + { + type: 'tool-call', + toolName: 'foo', + toolCallId: 'call_only', + input: {}, + } as StepContentPart, + ]), + ]; + + const result = mapAiStepsToToolCallLogs(steps); + + expect(result).toHaveLength(1); + expect(result[0].toolName).toBe('foo'); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/map-ai-steps-to-tool-call-logs.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/map-ai-steps-to-tool-call-logs.util.ts new file mode 100644 index 0000000000..fb68ec44f6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/map-ai-steps-to-tool-call-logs.util.ts @@ -0,0 +1,142 @@ +import { type StepResult, type ToolSet } from 'ai'; + +import { type AiToolCallLog } from 'twenty-shared/workflow'; + +import { + TRUNCATION_SENTINEL, + truncateStringToUtf8ByteBudget, +} from 'src/utils/truncate-string-to-utf8-byte-budget.util'; + +const DEFAULT_MAX_TOOL_INPUT_BYTES = 32_000; +const DEFAULT_MAX_TOOL_OUTPUT_BYTES = 64_000; +const DEFAULT_MAX_TOOL_CALLS_PER_STEP = 200; +const MAX_ERROR_MESSAGE_LENGTH = 2_000; + +const NOISY_RECORD_KEYS = new Set(['searchVector']); + +const stripNoisyKeysDeep = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(stripNoisyKeysDeep); + } + + if (value !== null && typeof value === 'object') { + const sanitized: Record = {}; + + for (const [key, nested] of Object.entries(value)) { + if (NOISY_RECORD_KEYS.has(key)) { + continue; + } + + sanitized[key] = stripNoisyKeysDeep(nested); + } + + return sanitized; + } + + return value; +}; + +const truncateUnknownForLog = (value: unknown, maxBytes: number): unknown => { + if (value === undefined || value === null) { + return value; + } + + if (typeof value === 'string') { + const { value: truncatedValue, truncated } = truncateStringToUtf8ByteBudget( + value, + maxBytes, + ); + + return truncated ? truncatedValue : value; + } + + let serialized: string; + + try { + serialized = JSON.stringify(value); + } catch { + return TRUNCATION_SENTINEL; + } + + const { value: truncatedValue, truncated } = truncateStringToUtf8ByteBudget( + serialized, + maxBytes, + ); + + return truncated ? truncatedValue : value; +}; + +export type MapAiStepsToToolCallLogsOptions = { + maxToolInputBytes?: number; + maxToolOutputBytes?: number; + maxToolCallsPerStep?: number; +}; + +export const mapAiStepsToToolCallLogs = ( + steps: StepResult[], + options: MapAiStepsToToolCallLogsOptions = {}, +): AiToolCallLog[] => { + const maxToolInputBytes = + options.maxToolInputBytes ?? DEFAULT_MAX_TOOL_INPUT_BYTES; + const maxToolOutputBytes = + options.maxToolOutputBytes ?? DEFAULT_MAX_TOOL_OUTPUT_BYTES; + const maxToolCallsPerStep = + options.maxToolCallsPerStep ?? DEFAULT_MAX_TOOL_CALLS_PER_STEP; + + const ordered: AiToolCallLog[] = []; + const openByCallId = new Map(); + + for (const step of steps) { + if (ordered.length >= maxToolCallsPerStep) { + break; + } + + for (const part of step.content) { + if (ordered.length >= maxToolCallsPerStep) { + break; + } + + if (part.type === 'tool-call') { + const entry: AiToolCallLog = { + toolName: part.toolName, + toolCallId: part.toolCallId, + input: truncateUnknownForLog(part.input, maxToolInputBytes), + state: 'started', + providerExecuted: + 'providerExecuted' in part && part.providerExecuted === true, + }; + openByCallId.set(part.toolCallId, entry); + ordered.push(entry); + continue; + } + + if (part.type === 'tool-result') { + const entry = openByCallId.get(part.toolCallId); + + if (entry) { + entry.output = truncateUnknownForLog( + stripNoisyKeysDeep(part.output), + maxToolOutputBytes, + ); + entry.state = 'success'; + } + continue; + } + + if (part.type === 'tool-error') { + const entry = openByCallId.get(part.toolCallId); + + if (entry) { + entry.errorMessage = String(part.error).slice( + 0, + MAX_ERROR_MESSAGE_LENGTH, + ); + entry.state = 'error'; + } + continue; + } + } + } + + return ordered; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-nested-relation-fields.type.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-nested-relation-fields.type.ts index 9345541d1a..ddabc713ac 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-nested-relation-fields.type.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-nested-relation-fields.type.ts @@ -18,7 +18,11 @@ export type DisconnectObject = { }; export type EntityRelationFields = { - [K in keyof T]: T[K] extends BaseWorkspaceEntity | null ? K : never; + [K in keyof T]: NonNullable extends + | BaseWorkspaceEntity + | BaseWorkspaceEntity[] + ? K + : never; }[keyof T]; export type QueryDeepPartialEntityWithNestedRelationFields = Omit< diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index fc3da96b4e..65d87046f5 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -239,6 +239,7 @@ describe('WorkspaceEntityManager', () => { IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false, IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: false, IS_SETTINGS_DISCOVERY_HERO_ENABLED: false, + IS_WORKFLOW_RUN_STEP_LOGS_ENABLED: false, }, userWorkspaceRoleMap: {}, eventEmitterService: { diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/__tests__/__snapshots__/get-standard-object-metadata-related-entity-ids.util.spec.ts.snap b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/__tests__/__snapshots__/get-standard-object-metadata-related-entity-ids.util.spec.ts.snap index fbacf7bcff..3060ba42c9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/__tests__/__snapshots__/get-standard-object-metadata-related-entity-ids.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/__tests__/__snapshots__/get-standard-object-metadata-related-entity-ids.util.spec.ts.snap @@ -2327,7 +2327,7 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "id": "00000000-0000-0000-0000-000000000697", }, "searchVector": { - "id": "00000000-0000-0000-0000-000000000702", + "id": "00000000-0000-0000-0000-000000000703", }, "startedAt": { "id": "00000000-0000-0000-0000-000000000694", @@ -2338,9 +2338,12 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "status": { "id": "00000000-0000-0000-0000-000000000696", }, - "timelineActivities": { + "stepLogs": { "id": "00000000-0000-0000-0000-000000000701", }, + "timelineActivities": { + "id": "00000000-0000-0000-0000-000000000702", + }, "updatedAt": { "id": "00000000-0000-0000-0000-000000000688", }, @@ -2354,80 +2357,80 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "id": "00000000-0000-0000-0000-000000000691", }, }, - "id": "00000000-0000-0000-0000-000000000725", + "id": "00000000-0000-0000-0000-000000000726", "views": { "allWorkflowRuns": { - "id": "00000000-0000-0000-0000-000000000709", + "id": "00000000-0000-0000-0000-000000000710", "viewFieldGroups": {}, "viewFields": { "createdBy": { - "id": "00000000-0000-0000-0000-000000000707", + "id": "00000000-0000-0000-0000-000000000708", }, "name": { - "id": "00000000-0000-0000-0000-000000000703", - }, - "startedAt": { - "id": "00000000-0000-0000-0000-000000000706", - }, - "status": { - "id": "00000000-0000-0000-0000-000000000705", - }, - "workflow": { "id": "00000000-0000-0000-0000-000000000704", }, + "startedAt": { + "id": "00000000-0000-0000-0000-000000000707", + }, + "status": { + "id": "00000000-0000-0000-0000-000000000706", + }, + "workflow": { + "id": "00000000-0000-0000-0000-000000000705", + }, "workflowVersion": { - "id": "00000000-0000-0000-0000-000000000708", + "id": "00000000-0000-0000-0000-000000000709", }, }, "viewGroups": {}, }, "workflowRunRecordPageFields": { - "id": "00000000-0000-0000-0000-000000000724", + "id": "00000000-0000-0000-0000-000000000725", "viewFieldGroups": { "general": { - "id": "00000000-0000-0000-0000-000000000722", + "id": "00000000-0000-0000-0000-000000000723", }, "system": { - "id": "00000000-0000-0000-0000-000000000723", + "id": "00000000-0000-0000-0000-000000000724", }, }, "viewFields": { "createdAt": { - "id": "00000000-0000-0000-0000-000000000715", - }, - "createdBy": { "id": "00000000-0000-0000-0000-000000000716", }, - "endedAt": { - "id": "00000000-0000-0000-0000-000000000714", - }, - "enqueuedAt": { + "createdBy": { "id": "00000000-0000-0000-0000-000000000717", }, - "startedAt": { - "id": "00000000-0000-0000-0000-000000000713", + "endedAt": { + "id": "00000000-0000-0000-0000-000000000715", }, - "state": { + "enqueuedAt": { "id": "00000000-0000-0000-0000-000000000718", }, - "status": { - "id": "00000000-0000-0000-0000-000000000710", + "startedAt": { + "id": "00000000-0000-0000-0000-000000000714", }, - "timelineActivities": { - "id": "00000000-0000-0000-0000-000000000721", - }, - "updatedAt": { + "state": { "id": "00000000-0000-0000-0000-000000000719", }, - "updatedBy": { - "id": "00000000-0000-0000-0000-000000000720", - }, - "workflow": { + "status": { "id": "00000000-0000-0000-0000-000000000711", }, - "workflowVersion": { + "timelineActivities": { + "id": "00000000-0000-0000-0000-000000000722", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000720", + }, + "updatedBy": { + "id": "00000000-0000-0000-0000-000000000721", + }, + "workflow": { "id": "00000000-0000-0000-0000-000000000712", }, + "workflowVersion": { + "id": "00000000-0000-0000-0000-000000000713", + }, }, "viewGroups": {}, }, @@ -2436,115 +2439,115 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "workflowVersion": { "fields": { "createdAt": { - "id": "00000000-0000-0000-0000-000000000727", - }, - "createdBy": { - "id": "00000000-0000-0000-0000-000000000739", - }, - "deletedAt": { - "id": "00000000-0000-0000-0000-000000000729", - }, - "id": { - "id": "00000000-0000-0000-0000-000000000726", - }, - "name": { - "id": "00000000-0000-0000-0000-000000000730", - }, - "position": { - "id": "00000000-0000-0000-0000-000000000734", - }, - "runs": { - "id": "00000000-0000-0000-0000-000000000735", - }, - "searchVector": { - "id": "00000000-0000-0000-0000-000000000738", - }, - "status": { - "id": "00000000-0000-0000-0000-000000000733", - }, - "steps": { - "id": "00000000-0000-0000-0000-000000000736", - }, - "timelineActivities": { - "id": "00000000-0000-0000-0000-000000000737", - }, - "trigger": { - "id": "00000000-0000-0000-0000-000000000732", - }, - "updatedAt": { "id": "00000000-0000-0000-0000-000000000728", }, - "updatedBy": { + "createdBy": { "id": "00000000-0000-0000-0000-000000000740", }, - "workflow": { + "deletedAt": { + "id": "00000000-0000-0000-0000-000000000730", + }, + "id": { + "id": "00000000-0000-0000-0000-000000000727", + }, + "name": { "id": "00000000-0000-0000-0000-000000000731", }, + "position": { + "id": "00000000-0000-0000-0000-000000000735", + }, + "runs": { + "id": "00000000-0000-0000-0000-000000000736", + }, + "searchVector": { + "id": "00000000-0000-0000-0000-000000000739", + }, + "status": { + "id": "00000000-0000-0000-0000-000000000734", + }, + "steps": { + "id": "00000000-0000-0000-0000-000000000737", + }, + "timelineActivities": { + "id": "00000000-0000-0000-0000-000000000738", + }, + "trigger": { + "id": "00000000-0000-0000-0000-000000000733", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000729", + }, + "updatedBy": { + "id": "00000000-0000-0000-0000-000000000741", + }, + "workflow": { + "id": "00000000-0000-0000-0000-000000000732", + }, }, - "id": "00000000-0000-0000-0000-000000000760", + "id": "00000000-0000-0000-0000-000000000761", "views": { "allWorkflowVersions": { - "id": "00000000-0000-0000-0000-000000000746", + "id": "00000000-0000-0000-0000-000000000747", "viewFieldGroups": {}, "viewFields": { "name": { - "id": "00000000-0000-0000-0000-000000000741", + "id": "00000000-0000-0000-0000-000000000742", }, "runs": { - "id": "00000000-0000-0000-0000-000000000745", + "id": "00000000-0000-0000-0000-000000000746", }, "status": { - "id": "00000000-0000-0000-0000-000000000743", - }, - "updatedAt": { "id": "00000000-0000-0000-0000-000000000744", }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000745", + }, "workflow": { - "id": "00000000-0000-0000-0000-000000000742", + "id": "00000000-0000-0000-0000-000000000743", }, }, "viewGroups": {}, }, "workflowVersionRecordPageFields": { - "id": "00000000-0000-0000-0000-000000000759", + "id": "00000000-0000-0000-0000-000000000760", "viewFieldGroups": { "general": { - "id": "00000000-0000-0000-0000-000000000757", + "id": "00000000-0000-0000-0000-000000000758", }, "system": { - "id": "00000000-0000-0000-0000-000000000758", + "id": "00000000-0000-0000-0000-000000000759", }, }, "viewFields": { "createdAt": { - "id": "00000000-0000-0000-0000-000000000750", - }, - "createdBy": { - "id": "00000000-0000-0000-0000-000000000752", - }, - "runs": { - "id": "00000000-0000-0000-0000-000000000755", - }, - "status": { - "id": "00000000-0000-0000-0000-000000000747", - }, - "steps": { "id": "00000000-0000-0000-0000-000000000751", }, - "timelineActivities": { - "id": "00000000-0000-0000-0000-000000000756", - }, - "trigger": { - "id": "00000000-0000-0000-0000-000000000749", - }, - "updatedAt": { + "createdBy": { "id": "00000000-0000-0000-0000-000000000753", }, - "updatedBy": { + "runs": { + "id": "00000000-0000-0000-0000-000000000756", + }, + "status": { + "id": "00000000-0000-0000-0000-000000000748", + }, + "steps": { + "id": "00000000-0000-0000-0000-000000000752", + }, + "timelineActivities": { + "id": "00000000-0000-0000-0000-000000000757", + }, + "trigger": { + "id": "00000000-0000-0000-0000-000000000750", + }, + "updatedAt": { "id": "00000000-0000-0000-0000-000000000754", }, + "updatedBy": { + "id": "00000000-0000-0000-0000-000000000755", + }, "workflow": { - "id": "00000000-0000-0000-0000-000000000748", + "id": "00000000-0000-0000-0000-000000000749", }, }, "viewGroups": {}, @@ -2554,123 +2557,123 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "workspaceMember": { "fields": { "accountOwnerForCompanies": { - "id": "00000000-0000-0000-0000-000000000774", - }, - "assignedTasks": { - "id": "00000000-0000-0000-0000-000000000772", - }, - "avatarUrl": { - "id": "00000000-0000-0000-0000-000000000769", - }, - "blocklist": { - "id": "00000000-0000-0000-0000-000000000776", - }, - "calendarEventParticipants": { - "id": "00000000-0000-0000-0000-000000000777", - }, - "calendarStartDay": { - "id": "00000000-0000-0000-0000-000000000783", - }, - "colorScheme": { - "id": "00000000-0000-0000-0000-000000000767", - }, - "createdAt": { - "id": "00000000-0000-0000-0000-000000000762", - }, - "createdBy": { - "id": "00000000-0000-0000-0000-000000000785", - }, - "dateFormat": { - "id": "00000000-0000-0000-0000-000000000780", - }, - "deletedAt": { - "id": "00000000-0000-0000-0000-000000000764", - }, - "id": { - "id": "00000000-0000-0000-0000-000000000761", - }, - "locale": { - "id": "00000000-0000-0000-0000-000000000768", - }, - "messageParticipants": { "id": "00000000-0000-0000-0000-000000000775", }, - "name": { - "id": "00000000-0000-0000-0000-000000000766", - }, - "numberFormat": { - "id": "00000000-0000-0000-0000-000000000784", - }, - "ownedOpportunities": { + "assignedTasks": { "id": "00000000-0000-0000-0000-000000000773", }, - "position": { - "id": "00000000-0000-0000-0000-000000000765", - }, - "searchVector": { - "id": "00000000-0000-0000-0000-000000000782", - }, - "timeFormat": { - "id": "00000000-0000-0000-0000-000000000781", - }, - "timeZone": { - "id": "00000000-0000-0000-0000-000000000779", - }, - "timelineActivities": { - "id": "00000000-0000-0000-0000-000000000778", - }, - "updatedAt": { - "id": "00000000-0000-0000-0000-000000000763", - }, - "updatedBy": { - "id": "00000000-0000-0000-0000-000000000786", - }, - "userEmail": { + "avatarUrl": { "id": "00000000-0000-0000-0000-000000000770", }, - "userId": { + "blocklist": { + "id": "00000000-0000-0000-0000-000000000777", + }, + "calendarEventParticipants": { + "id": "00000000-0000-0000-0000-000000000778", + }, + "calendarStartDay": { + "id": "00000000-0000-0000-0000-000000000784", + }, + "colorScheme": { + "id": "00000000-0000-0000-0000-000000000768", + }, + "createdAt": { + "id": "00000000-0000-0000-0000-000000000763", + }, + "createdBy": { + "id": "00000000-0000-0000-0000-000000000786", + }, + "dateFormat": { + "id": "00000000-0000-0000-0000-000000000781", + }, + "deletedAt": { + "id": "00000000-0000-0000-0000-000000000765", + }, + "id": { + "id": "00000000-0000-0000-0000-000000000762", + }, + "locale": { + "id": "00000000-0000-0000-0000-000000000769", + }, + "messageParticipants": { + "id": "00000000-0000-0000-0000-000000000776", + }, + "name": { + "id": "00000000-0000-0000-0000-000000000767", + }, + "numberFormat": { + "id": "00000000-0000-0000-0000-000000000785", + }, + "ownedOpportunities": { + "id": "00000000-0000-0000-0000-000000000774", + }, + "position": { + "id": "00000000-0000-0000-0000-000000000766", + }, + "searchVector": { + "id": "00000000-0000-0000-0000-000000000783", + }, + "timeFormat": { + "id": "00000000-0000-0000-0000-000000000782", + }, + "timeZone": { + "id": "00000000-0000-0000-0000-000000000780", + }, + "timelineActivities": { + "id": "00000000-0000-0000-0000-000000000779", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000764", + }, + "updatedBy": { + "id": "00000000-0000-0000-0000-000000000787", + }, + "userEmail": { "id": "00000000-0000-0000-0000-000000000771", }, + "userId": { + "id": "00000000-0000-0000-0000-000000000772", + }, }, - "id": "00000000-0000-0000-0000-000000000799", + "id": "00000000-0000-0000-0000-000000000800", "views": { "allWorkspaceMembers": { - "id": "00000000-0000-0000-0000-000000000798", + "id": "00000000-0000-0000-0000-000000000799", "viewFieldGroups": {}, "viewFields": { "assignedTasks": { - "id": "00000000-0000-0000-0000-000000000797", + "id": "00000000-0000-0000-0000-000000000798", }, "avatarUrl": { - "id": "00000000-0000-0000-0000-000000000789", - }, - "colorScheme": { "id": "00000000-0000-0000-0000-000000000790", }, - "createdAt": { - "id": "00000000-0000-0000-0000-000000000795", - }, - "dateFormat": { - "id": "00000000-0000-0000-0000-000000000793", - }, - "locale": { + "colorScheme": { "id": "00000000-0000-0000-0000-000000000791", }, - "name": { - "id": "00000000-0000-0000-0000-000000000787", - }, - "ownedOpportunities": { + "createdAt": { "id": "00000000-0000-0000-0000-000000000796", }, - "timeFormat": { + "dateFormat": { "id": "00000000-0000-0000-0000-000000000794", }, - "timeZone": { + "locale": { "id": "00000000-0000-0000-0000-000000000792", }, - "userEmail": { + "name": { "id": "00000000-0000-0000-0000-000000000788", }, + "ownedOpportunities": { + "id": "00000000-0000-0000-0000-000000000797", + }, + "timeFormat": { + "id": "00000000-0000-0000-0000-000000000795", + }, + "timeZone": { + "id": "00000000-0000-0000-0000-000000000793", + }, + "userEmail": { + "id": "00000000-0000-0000-0000-000000000789", + }, }, "viewGroups": {}, }, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-workflow-run-standard-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-workflow-run-standard-flat-field-metadata.util.ts index a6dc653799..f43a2fbbe9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-workflow-run-standard-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-workflow-run-standard-flat-field-metadata.util.ts @@ -308,6 +308,26 @@ export const buildWorkflowRunStandardFlatFieldMetadatas = ({ twentyStandardApplicationId, now, }), + stepLogs: createStandardFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: 'stepLogs', + type: FieldMetadataType.RAW_JSON, + label: i18nLabel(msg`Step logs`), + description: i18nLabel( + msg`Per-step observability payload (token usage, tool calls, log entries)`, + ), + icon: 'IconTerminal2', + isSystem: true, + isNullable: true, + isUIReadOnly: true, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), position: createStandardFieldFlatMetadata({ objectName, workspaceId, diff --git a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts index 2be595d7f5..0c6fdf470e 100644 --- a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts @@ -1,7 +1,10 @@ import { registerEnumType } from '@nestjs/graphql'; import { type ActorMetadata, FieldMetadataType } from 'twenty-shared/types'; -import { type WorkflowRunStepInfos } from 'twenty-shared/workflow'; +import { + type WorkflowRunStepInfos, + type WorkflowRunStepLogs, +} from 'twenty-shared/workflow'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util'; @@ -66,6 +69,7 @@ export class WorkflowRunWorkspaceEntity extends BaseWorkspaceEntity { createdBy: ActorMetadata; updatedBy: ActorMetadata; state: WorkflowRunState; + stepLogs: WorkflowRunStepLogs | null; position: number; searchVector: string; workflowVersion: EntityRelation; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service.ts index 28f4849729..e08631ec3b 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service.ts @@ -54,7 +54,10 @@ export class WorkflowVersionStepHelpersWorkspaceService { { shouldBypassPermissionChecks: true }, ); - const updateData: Partial = {}; + const updateData: Pick< + Partial, + 'steps' | 'trigger' + > = {}; if (steps !== undefined) { updateData.steps = steps; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts index b887c2e352..c69c87e9da 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts @@ -12,15 +12,17 @@ import { DelayWorkflowAction } from 'src/modules/workflow/workflow-executor/work import { EmptyWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/empty/empty.workflow-action'; import { FilterWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action'; import { FormWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form.workflow-action'; +import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action'; import { IfElseWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/if-else.workflow-action'; import { IteratorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator.workflow-action'; import { LogicFunctionWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function.workflow-action'; +import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action'; +import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action'; import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action'; import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action'; import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action'; import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action'; import { UpsertRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action'; -import { ToolExecutorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action'; import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; @Injectable() @@ -37,7 +39,9 @@ export class WorkflowActionFactory { private readonly filterWorkflowAction: FilterWorkflowAction, private readonly ifElseWorkflowAction: IfElseWorkflowAction, private readonly iteratorWorkflowAction: IteratorWorkflowAction, - private readonly toolExecutorWorkflowAction: ToolExecutorWorkflowAction, + private readonly httpRequestWorkflowAction: HttpRequestWorkflowAction, + private readonly sendEmailWorkflowAction: SendEmailWorkflowAction, + private readonly draftEmailWorkflowAction: DraftEmailWorkflowAction, private readonly aiAgentWorkflowAction: AiAgentWorkflowAction, private readonly emptyWorkflowAction: EmptyWorkflowAction, private readonly delayWorkflowAction: DelayWorkflowAction, @@ -50,9 +54,9 @@ export class WorkflowActionFactory { case WorkflowActionType.LOGIC_FUNCTION: return this.logicFunctionWorkflowAction; case WorkflowActionType.SEND_EMAIL: - return this.toolExecutorWorkflowAction; + return this.sendEmailWorkflowAction; case WorkflowActionType.DRAFT_EMAIL: - return this.toolExecutorWorkflowAction; + return this.draftEmailWorkflowAction; case WorkflowActionType.CREATE_RECORD: return this.createRecordWorkflowAction; case WorkflowActionType.UPSERT_RECORD: @@ -72,7 +76,7 @@ export class WorkflowActionFactory { case WorkflowActionType.ITERATOR: return this.iteratorWorkflowAction; case WorkflowActionType.HTTP_REQUEST: - return this.toolExecutorWorkflowAction; + return this.httpRequestWorkflowAction; case WorkflowActionType.AI_AGENT: return this.aiAgentWorkflowAction; case WorkflowActionType.EMPTY: diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts index 16728b1bce..791c495050 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { resolveInput } from 'twenty-shared/utils'; @@ -6,6 +6,7 @@ import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/inte import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum'; import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service'; +import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @@ -17,14 +18,19 @@ import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-e import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; +import { buildAiAgentStepLog } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/utils/build-ai-agent-step-log.util'; +import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; import { isWorkflowAiAgentAction } from './guards/is-workflow-ai-agent-action.guard'; @Injectable() export class AiAgentWorkflowAction implements WorkflowAction { + private readonly logger = new Logger(AiAgentWorkflowAction.name); + constructor( private readonly aiAgentExecutionService: AgentAsyncExecutorService, private readonly workflowExecutionContextService: WorkflowExecutionContextService, + private readonly workflowRunStepLogService: WorkflowRunStepLogWorkspaceService, @InjectWorkspaceScopedRepository(AgentEntity) private readonly agentRepository: WorkspaceScopedRepository, ) {} @@ -73,27 +79,73 @@ export class AiAgentWorkflowAction implements WorkflowAction { ? executionContext.authContext.userWorkspaceId : null; - const { result, hasNoMoreAvailableCredits } = - await this.aiAgentExecutionService.executeAgent({ - agent, - userPrompt: resolveInput(prompt, context) as string, - actorContext: executionContext.isActingOnBehalfOfUser - ? executionContext.initiator - : undefined, - authContext: executionContext.authContext, - workspaceId, - userWorkspaceId, - operationType: UsageOperationType.AI_WORKFLOW_TOKEN, - }); + const startedAtMs = Date.now(); - if (hasNoMoreAvailableCredits) { + const executionResult = await this.aiAgentExecutionService.executeAgent({ + agent, + userPrompt: resolveInput(prompt, context) as string, + actorContext: executionContext.isActingOnBehalfOfUser + ? executionContext.initiator + : undefined, + authContext: executionContext.authContext, + workspaceId, + userWorkspaceId, + operationType: UsageOperationType.AI_WORKFLOW_TOKEN, + }); + + const durationMs = Date.now() - startedAtMs; + + await this.persistStepLog({ + workflowRunId: runInfo.workflowRunId, + workspaceId, + stepId: currentStepId, + executionResult, + durationMs, + }); + + if (executionResult.hasNoMoreAvailableCredits) { return { error: 'AI agent stopped: no more available credits.', }; } return { - result, + result: executionResult.result, }; } + + private async persistStepLog({ + workflowRunId, + workspaceId, + stepId, + executionResult, + durationMs, + }: { + workflowRunId: string; + workspaceId: string; + stepId: string; + executionResult: AgentExecutionResult; + durationMs: number; + }): Promise { + const stepLog = buildAiAgentStepLog({ executionResult, durationMs }); + + if (!stepLog) { + return; + } + + try { + await this.workflowRunStepLogService.setStepLog({ + workflowRunId, + workspaceId, + stepId, + stepLog, + }); + } catch (error) { + this.logger.warn( + `Failed to persist step log for workflowRun=${workflowRunId} step=${stepId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/utils/__tests__/build-ai-agent-step-log.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/utils/__tests__/build-ai-agent-step-log.util.spec.ts new file mode 100644 index 0000000000..f09dae0c0f --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/utils/__tests__/build-ai-agent-step-log.util.spec.ts @@ -0,0 +1,98 @@ +import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type'; +import { buildAiAgentStepLog } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/utils/build-ai-agent-step-log.util'; + +jest.mock( + 'src/engine/metadata-modules/ai/ai-agent-execution/utils/map-ai-steps-to-tool-call-logs.util', + () => ({ + mapAiStepsToToolCallLogs: jest.fn().mockReturnValue([ + { + toolName: 'web_search', + toolCallId: 'call-1', + state: 'success', + }, + ]), + }), +); + +const baseExecutionResult: AgentExecutionResult = { + result: { answer: 'hello' }, + usage: { + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + inputTokenDetails: { cacheReadTokens: 20 }, + outputTokenDetails: { reasoningTokens: 10 }, + } as AgentExecutionResult['usage'], + cacheCreationTokens: 5, + nativeWebSearchCallCount: 2, + hasNoMoreAvailableCredits: false, + modelId: 'claude-sonnet-4', + totalCostInDollars: 0.012, + creditsUsedMicro: 12_000, + steps: [] as AgentExecutionResult['steps'], +}; + +describe('buildAiAgentStepLog', () => { + it('returns null when the execution never resolved a model', () => { + const stepLog = buildAiAgentStepLog({ + executionResult: { ...baseExecutionResult, modelId: undefined }, + durationMs: 1234, + }); + + expect(stepLog).toBeNull(); + }); + + it('builds an AI_AGENT step log with usage, cost, and tool calls', () => { + const stepLog = buildAiAgentStepLog({ + executionResult: baseExecutionResult, + durationMs: 1234, + }); + + if (stepLog === null || stepLog.details.type !== 'AI_AGENT') { + throw new Error('Expected AI_AGENT details'); + } + + expect(stepLog.details.modelId).toBe('claude-sonnet-4'); + expect(stepLog.details.durationMs).toBe(1234); + expect(stepLog.details.usage).toEqual({ + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 10, + cacheReadTokens: 20, + cacheCreationTokens: 5, + totalTokens: 155, + }); + expect(stepLog.details.cost).toEqual({ + totalCostInDollars: 0.012, + creditsUsedMicro: 12_000, + }); + expect(stepLog.details.nativeWebSearchCallCount).toBe(2); + expect(stepLog.details.toolCalls).toHaveLength(1); + expect(stepLog.entries).toEqual([]); + }); + + it('falls back to zero usage / cost when the agent did not report them', () => { + const stepLog = buildAiAgentStepLog({ + executionResult: { + ...baseExecutionResult, + usage: {} as AgentExecutionResult['usage'], + cacheCreationTokens: 0, + totalCostInDollars: undefined, + creditsUsedMicro: undefined, + steps: undefined, + }, + durationMs: 100, + }); + + if (stepLog === null || stepLog.details.type !== 'AI_AGENT') { + throw new Error('Expected AI_AGENT details'); + } + + expect(stepLog.details.usage.inputTokens).toBe(0); + expect(stepLog.details.usage.outputTokens).toBe(0); + expect(stepLog.details.usage.totalTokens).toBe(0); + expect(stepLog.details.cost.totalCostInDollars).toBe(0); + expect(stepLog.details.cost.creditsUsedMicro).toBe(0); + expect(stepLog.details.toolCalls).toEqual([]); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/utils/build-ai-agent-step-log.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/utils/build-ai-agent-step-log.util.ts new file mode 100644 index 0000000000..af279bc01b --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/utils/build-ai-agent-step-log.util.ts @@ -0,0 +1,52 @@ +import { + type AiAgentStepLogDetails, + type WorkflowRunStepLog, +} from 'twenty-shared/workflow'; + +import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type'; +import { mapAiStepsToToolCallLogs } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/map-ai-steps-to-tool-call-logs.util'; + +export const buildAiAgentStepLog = ({ + executionResult, + durationMs, +}: { + executionResult: AgentExecutionResult; + durationMs: number; +}): WorkflowRunStepLog | null => { + if (!executionResult.modelId) { + return null; + } + + const toolCalls = executionResult.steps + ? mapAiStepsToToolCallLogs(executionResult.steps) + : []; + + const details: AiAgentStepLogDetails = { + type: 'AI_AGENT', + modelId: executionResult.modelId, + usage: { + inputTokens: executionResult.usage.inputTokens ?? 0, + outputTokens: executionResult.usage.outputTokens ?? 0, + reasoningTokens: + executionResult.usage.outputTokenDetails?.reasoningTokens, + cacheReadTokens: executionResult.usage.inputTokenDetails?.cacheReadTokens, + cacheCreationTokens: executionResult.cacheCreationTokens, + totalTokens: + (executionResult.usage.totalTokens ?? 0) + + executionResult.cacheCreationTokens, + }, + cost: { + totalCostInDollars: executionResult.totalCostInDollars ?? 0, + creditsUsedMicro: executionResult.creditsUsedMicro ?? 0, + }, + nativeWebSearchCallCount: executionResult.nativeWebSearchCallCount, + toolCalls, + durationMs, + }; + + return { + details, + entries: [], + sizeBytes: 0, + }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code-action.module.ts index c030c4a54c..aa6303056e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code-action.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code-action.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { LogicFunctionModule } from 'src/engine/core-modules/logic-function/logic-function.module'; import { CodeWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action'; +import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module'; @Module({ - imports: [LogicFunctionModule], + imports: [LogicFunctionModule, WorkflowRunModule], providers: [CodeWorkflowAction], exports: [CodeWorkflowAction], }) diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts index b5189c39ca..ebb6772296 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts @@ -1,9 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { resolveInput } from 'twenty-shared/utils'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; +import { type LogicFunctionExecuteResult } from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface'; import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; import { WorkflowStepExecutorException, @@ -14,11 +15,16 @@ import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executo import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; import { isWorkflowCodeAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/guards/is-workflow-code-action.guard'; import { type WorkflowCodeActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-input.type'; +import { buildCodeStepLog } from 'src/modules/workflow/workflow-executor/workflow-actions/code/utils/build-code-step-log.util'; +import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; @Injectable() export class CodeWorkflowAction implements WorkflowAction { + private readonly logger = new Logger(CodeWorkflowAction.name); + constructor( private readonly logicFunctionExecutorService: LogicFunctionExecutorService, + private readonly workflowRunStepLogService: WorkflowRunStepLogWorkspaceService, ) {} async execute({ @@ -52,10 +58,44 @@ export class CodeWorkflowAction implements WorkflowAction { payload: workflowActionInput.logicFunctionInput, }); + await this.persistStepLog({ + workflowRunId: runInfo.workflowRunId, + workspaceId, + stepId: currentStepId, + result, + }); + if (result.error) { return { error: result.error.errorMessage }; } return { result: result.data || {} }; } + + private async persistStepLog({ + workflowRunId, + workspaceId, + stepId, + result, + }: { + workflowRunId: string; + workspaceId: string; + stepId: string; + result: LogicFunctionExecuteResult; + }): Promise { + try { + await this.workflowRunStepLogService.setStepLog({ + workflowRunId, + workspaceId, + stepId, + stepLog: buildCodeStepLog(result), + }); + } catch (error) { + this.logger.warn( + `Failed to persist step log for workflowRun=${workflowRunId} step=${stepId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/utils/build-code-step-log.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/utils/build-code-step-log.util.ts new file mode 100644 index 0000000000..92f916aa41 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/utils/build-code-step-log.util.ts @@ -0,0 +1,72 @@ +import { type WorkflowRunStepLog } from 'twenty-shared/workflow'; + +import { parseApplicationLogLines } from 'src/engine/core-modules/application-logs/utils/parse-application-log-lines'; +import { + type LogicFunctionExecuteError, + type LogicFunctionExecuteResult, +} from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface'; + +const MAX_ENTRIES = 500; +const MAX_MESSAGE_LENGTH = 4_000; +const MAX_STACK_TRACE_LENGTH = 8_000; + +const truncate = (value: string, max: number): string => + value.length > max ? `${value.slice(0, max)}…[truncated]` : value; + +type StepLogEntry = WorkflowRunStepLog['entries'][number]; + +type LogLevel = StepLogEntry['level']; + +const LEVEL_BY_INPUT: Record = { + DEBUG: 'debug', + INFO: 'info', + WARN: 'warn', + ERROR: 'error', +}; + +const normalizeLevel = (rawLevel: string): LogLevel => + LEVEL_BY_INPUT[rawLevel.toUpperCase()] ?? 'info'; + +const flattenStackTrace = ( + stackTrace: LogicFunctionExecuteError['stackTrace'], +): string => + Array.isArray(stackTrace) ? stackTrace.join('\n') : (stackTrace ?? ''); + +export const buildCodeStepLog = ( + result: LogicFunctionExecuteResult, +): WorkflowRunStepLog => { + const parsedLines = parseApplicationLogLines(result.logs ?? ''); + const droppedEntries = Math.max(0, parsedLines.length - MAX_ENTRIES); + + const entries: StepLogEntry[] = parsedLines + .slice(0, MAX_ENTRIES) + .map((line) => ({ + timestamp: line.timestamp.toISOString(), + level: normalizeLevel(line.level), + message: truncate(line.message, MAX_MESSAGE_LENGTH), + })); + + const error = result.error + ? { + type: result.error.errorType, + message: truncate(result.error.errorMessage, MAX_MESSAGE_LENGTH), + stackTrace: truncate( + flattenStackTrace(result.error.stackTrace), + MAX_STACK_TRACE_LENGTH, + ), + } + : null; + + return { + details: { + type: 'CODE', + durationMs: result.duration, + status: result.error ? 'ERROR' : 'SUCCESS', + error, + }, + entries, + truncated: + droppedEntries > 0 ? { droppedEntries, droppedBytes: 0 } : undefined, + sizeBytes: 0, + }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/__tests__/http-request.workflow-action.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/__tests__/http-request.workflow-action.spec.ts new file mode 100644 index 0000000000..92ac6f18a0 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/__tests__/http-request.workflow-action.spec.ts @@ -0,0 +1,129 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; +import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action'; +import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; +import { + type WorkflowAction, + WorkflowActionType, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; + +const baseSettings: WorkflowActionSettings = { + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + input: {}, +}; + +const buildHttpRequestStep = (input: Record): WorkflowAction => + ({ + id: 'step-1', + type: WorkflowActionType.HTTP_REQUEST, + name: 'HTTP Request', + valid: true, + settings: { ...baseSettings, input }, + }) as WorkflowAction; + +describe('HttpRequestWorkflowAction', () => { + let action: HttpRequestWorkflowAction; + let mockHttpTool: jest.Mocked>; + let mockSetStepLog: jest.Mock; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockHttpTool = { + execute: jest.fn().mockResolvedValue({ + result: { ok: true }, + error: undefined, + status: 200, + }), + }; + mockSetStepLog = jest.fn(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + HttpRequestWorkflowAction, + { provide: HttpTool, useValue: mockHttpTool }, + { + provide: WorkflowRunStepLogWorkspaceService, + useValue: { setStepLog: mockSetStepLog }, + }, + ], + }).compile(); + + action = module.get(HttpRequestWorkflowAction); + }); + + it('resolves variables in the request input and forwards them to the HTTP tool', async () => { + await action.execute({ + currentStepId: 'step-1', + steps: [ + buildHttpRequestStep({ + url: 'https://api.example.com/users/{{trigger.id}}', + method: 'GET', + }), + ], + context: { trigger: { id: '42' } }, + runInfo: { workspaceId: 'workspace-1', workflowRunId: 'run-1' }, + }); + + expect(mockHttpTool.execute).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://api.example.com/users/42', + method: 'GET', + }), + expect.objectContaining({ workspaceId: 'workspace-1' }), + ); + }); + + it('persists an HTTP_REQUEST step log', async () => { + await action.execute({ + currentStepId: 'step-1', + steps: [ + buildHttpRequestStep({ + url: 'https://api.example.com/users', + method: 'POST', + body: { name: 'John' }, + }), + ], + context: {}, + runInfo: { workspaceId: 'workspace-1', workflowRunId: 'run-1' }, + }); + + expect(mockSetStepLog).toHaveBeenCalledWith( + expect.objectContaining({ + workflowRunId: 'run-1', + workspaceId: 'workspace-1', + stepId: 'step-1', + stepLog: expect.objectContaining({ + details: expect.objectContaining({ type: 'HTTP_REQUEST' }), + }), + }), + ); + }); + + it('throws when the current step is not an HTTP request action', async () => { + await expect( + action.execute({ + currentStepId: 'step-1', + steps: [ + { + ...buildHttpRequestStep({ + url: 'https://example.com', + method: 'GET', + }), + type: WorkflowActionType.SEND_EMAIL, + } as WorkflowAction, + ], + context: {}, + runInfo: { workspaceId: 'workspace-1', workflowRunId: 'run-1' }, + }), + ).rejects.toThrow('Step is not an HTTP request action'); + + expect(mockHttpTool.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request-action.module.ts new file mode 100644 index 0000000000..fbb98a05a5 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request-action.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; +import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action'; +import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module'; + +@Module({ + imports: [ToolModule, WorkflowRunModule], + providers: [HttpRequestWorkflowAction], + exports: [HttpRequestWorkflowAction], +}) +export class HttpRequestActionModule {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts new file mode 100644 index 0000000000..9c74ee99bd --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; + +import { type WorkflowRunStepLog } from 'twenty-shared/workflow'; + +import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; +import { + WorkflowStepExecutorException, + WorkflowStepExecutorExceptionCode, +} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; +import { isWorkflowHttpRequestAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/guards/is-workflow-http-request-action.guard'; +import { type WorkflowHttpRequestActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type'; +import { buildHttpRequestStepLog } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/utils/build-http-request-step-log.util'; +import { ToolBackedWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-backed/tool-backed.workflow-action'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; + +@Injectable() +export class HttpRequestWorkflowAction extends ToolBackedWorkflowAction { + constructor( + private readonly httpTool: HttpTool, + workflowRunStepLogService: WorkflowRunStepLogWorkspaceService, + ) { + super(HttpRequestWorkflowAction.name, workflowRunStepLogService); + } + + protected getTool(): Tool { + return this.httpTool; + } + + protected assertStep(step: WorkflowAction): void { + if (!isWorkflowHttpRequestAction(step)) { + throw new WorkflowStepExecutorException( + 'Step is not an HTTP request action', + WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE, + ); + } + } + + protected buildStepLog({ + input, + output, + durationMs, + }: { + input: WorkflowHttpRequestActionInput; + output: ToolOutput; + durationMs: number; + }): WorkflowRunStepLog { + return buildHttpRequestStepLog({ input, output, durationMs }); + } +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/utils/__tests__/build-http-request-step-log.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/utils/__tests__/build-http-request-step-log.util.spec.ts new file mode 100644 index 0000000000..ed270ce62d --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/utils/__tests__/build-http-request-step-log.util.spec.ts @@ -0,0 +1,363 @@ +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { type WorkflowHttpRequestActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type'; +import { buildHttpRequestStepLog } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/utils/build-http-request-step-log.util'; + +const baseInput: WorkflowHttpRequestActionInput = { + url: 'https://api.example.com/widgets', + method: 'POST', + headers: { + 'content-type': 'application/json', + Authorization: 'Bearer super-secret-token', + }, + body: { hello: 'world' }, +}; + +const baseOutput: ToolOutput = { + success: true, + message: 'OK', + result: { id: 'abc' }, + status: 201, + statusText: 'Created', + headers: { + 'content-type': 'application/json', + 'set-cookie': 'sid=abc; HttpOnly', + }, +}; + +describe('buildHttpRequestStepLog', () => { + it('redacts sensitive request and response headers', () => { + const stepLog = buildHttpRequestStepLog({ + input: baseInput, + output: baseOutput, + durationMs: 42, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + expect(stepLog.details.request.headers.Authorization).toBe('[redacted]'); + expect(stepLog.details.request.headers['content-type']).toBe( + 'application/json', + ); + + expect(stepLog.details.response?.headers['set-cookie']).toBe('[redacted]'); + expect(stepLog.details.response?.headers['content-type']).toBe( + 'application/json', + ); + }); + + it('stringifies and reports body byte size', () => { + const stepLog = buildHttpRequestStepLog({ + input: baseInput, + output: baseOutput, + durationMs: 10, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + expect(stepLog.details.request.body).toBe('{"hello":"world"}'); + expect(stepLog.details.request.bodyBytes).toBe(17); + expect(stepLog.details.request.bodyTruncated).toBe(false); + + expect(stepLog.details.response?.body).toBe('{"id":"abc"}'); + expect(stepLog.details.response?.bodyBytes).toBe(12); + }); + + it('truncates oversized request bodies and marks bodyTruncated', () => { + const longPayload = 'x'.repeat(100_000); + + const stepLog = buildHttpRequestStepLog({ + input: { ...baseInput, body: { payload: longPayload } }, + output: baseOutput, + durationMs: 10, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + expect(stepLog.details.request.bodyTruncated).toBe(true); + expect(stepLog.details.request.body).toContain('truncated'); + expect(stepLog.details.request.bodyBytes).toBeGreaterThan(100_000); + }); + + it('truncates non-ASCII bodies by UTF-8 bytes, not UTF-16 code units', () => { + // CJK characters take 3 UTF-8 bytes each but 1 UTF-16 code unit. + // Before the byte-aware fix, `redacted.slice(0, 32_000)` on this payload + // would emit ~96 KB of UTF-8 (three times the intended cap). After the + // fix the truncated payload stays within the cap, plus at most one + // U+FFFD replacement char (~3 bytes) for a multi-byte sequence cut at + // the boundary. + const longCjkPayload = '日'.repeat(40_000); + + const stepLog = buildHttpRequestStepLog({ + input: { ...baseInput, body: { payload: longCjkPayload } }, + output: baseOutput, + durationMs: 10, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + expect(stepLog.details.request.bodyTruncated).toBe(true); + + const truncatedBody = stepLog.details.request.body ?? ''; + const truncatedByteLength = Buffer.byteLength( + truncatedBody.replace('…[truncated]', ''), + 'utf8', + ); + + expect(truncatedByteLength).toBeLessThanOrEqual(32_000 + 3); + }); + + it('omits response when the request never received one (transport error)', () => { + const stepLog = buildHttpRequestStepLog({ + input: baseInput, + output: { + success: false, + message: `HTTP POST request to ${baseInput.url} failed`, + error: 'ENOTFOUND api.example.com', + }, + durationMs: 30, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + expect(stepLog.details.response).toBeUndefined(); + expect(stepLog.details.error).toBe('ENOTFOUND api.example.com'); + }); + + it('captures response details when an HTTP error response is returned', () => { + const stepLog = buildHttpRequestStepLog({ + input: baseInput, + output: { + success: false, + message: 'failed', + error: '{"code":"invalid"}', + status: 422, + statusText: 'Unprocessable Entity', + headers: { 'content-type': 'application/json' }, + result: { code: 'invalid' }, + }, + durationMs: 55, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + expect(stepLog.details.response?.status).toBe(422); + expect(stepLog.details.response?.statusText).toBe('Unprocessable Entity'); + expect(stepLog.details.response?.body).toBe('{"code":"invalid"}'); + expect(stepLog.details.error).toBe('{"code":"invalid"}'); + }); + + it('redacts sensitive query-string parameters in the request URL', () => { + const stepLog = buildHttpRequestStepLog({ + input: { + ...baseInput, + url: 'https://api.example.com/data?page=1&api_key=AKIA-leaked&token=oauth-leaked&safe=ok', + }, + output: baseOutput, + durationMs: 1, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + const url = new URL(stepLog.details.request.url); + + expect(url.searchParams.get('api_key')).toBe('[redacted]'); + expect(url.searchParams.get('token')).toBe('[redacted]'); + expect(url.searchParams.get('page')).toBe('1'); + expect(url.searchParams.get('safe')).toBe('ok'); + }); + + it('leaves the URL untouched when there are no sensitive params', () => { + const stepLog = buildHttpRequestStepLog({ + input: { ...baseInput, url: 'https://api.example.com/widgets?page=2' }, + output: baseOutput, + durationMs: 1, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + expect(stepLog.details.request.url).toBe( + 'https://api.example.com/widgets?page=2', + ); + }); + + it('redacts sensitive query params even when the URL is unparseable (regression)', () => { + // Whitespace in the host trips up the WHATWG URL parser, so this URL + // is rejected by `new URL()`. Before the fallback was added, the catch + // branch returned the raw URL with secrets intact. + const unparseableUrl = + 'https://api example.com/data?page=1&api_key=AKIA-leaked&token=oauth-leaked&safe=ok'; + + expect(() => new URL(unparseableUrl)).toThrow(); + + const stepLog = buildHttpRequestStepLog({ + input: { ...baseInput, url: unparseableUrl }, + output: baseOutput, + durationMs: 1, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + const redactedUrl = stepLog.details.request.url; + + expect(redactedUrl).not.toContain('AKIA-leaked'); + expect(redactedUrl).not.toContain('oauth-leaked'); + expect(redactedUrl).toContain('api_key=[redacted]'); + expect(redactedUrl).toContain('token=[redacted]'); + expect(redactedUrl).toContain('page=1'); + expect(redactedUrl).toContain('safe=ok'); + }); + + it('redacts percent-encoded sensitive param names in unparseable URLs', () => { + // `api%5Fkey` decodes to `api_key` — the fallback must decode before + // matching against the sensitive-name set. + const unparseableUrl = 'https://api example.com/x?api%5Fkey=leaked'; + + expect(() => new URL(unparseableUrl)).toThrow(); + + const stepLog = buildHttpRequestStepLog({ + input: { ...baseInput, url: unparseableUrl }, + output: baseOutput, + durationMs: 1, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + expect(stepLog.details.request.url).not.toContain('leaked'); + expect(stepLog.details.request.url).toContain('api%5Fkey=[redacted]'); + }); + + it('redacts sensitive keys in JSON request bodies (deep)', () => { + const stepLog = buildHttpRequestStepLog({ + input: { + ...baseInput, + body: { + username: 'alice', + password: 'hunter2', + credentials: { client_secret: 'oauth-secret', clientId: 'public' }, + tokens: [{ access_token: 'aaa', issuedAt: 1 }], + } as unknown as WorkflowHttpRequestActionInput['body'], + }, + output: baseOutput, + durationMs: 1, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + const parsed = JSON.parse(stepLog.details.request.body ?? '{}'); + + expect(parsed.username).toBe('alice'); + expect(parsed.password).toBe('[redacted]'); + expect(parsed.credentials.client_secret).toBe('[redacted]'); + expect(parsed.credentials.clientId).toBe('public'); + expect(parsed.tokens[0].access_token).toBe('[redacted]'); + expect(parsed.tokens[0].issuedAt).toBe(1); + }); + + it('redacts sensitive keys in JSON response bodies (OAuth token endpoint shape)', () => { + const stepLog = buildHttpRequestStepLog({ + input: baseInput, + output: { + ...baseOutput, + result: { + token_type: 'Bearer', + access_token: 'leaked-access', + refresh_token: 'leaked-refresh', + expires_in: 3600, + }, + }, + durationMs: 1, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + const parsed = JSON.parse(stepLog.details.response?.body ?? '{}'); + + expect(parsed.access_token).toBe('[redacted]'); + expect(parsed.refresh_token).toBe('[redacted]'); + expect(parsed.token_type).toBe('Bearer'); + expect(parsed.expires_in).toBe(3600); + }); + + it('redacts sensitive keys when a request body arrives as a JSON string', () => { + const stepLog = buildHttpRequestStepLog({ + input: { + ...baseInput, + body: '{"username":"alice","password":"hunter2"}', + }, + output: baseOutput, + durationMs: 1, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + const parsed = JSON.parse(stepLog.details.request.body ?? '{}'); + + expect(parsed.password).toBe('[redacted]'); + expect(parsed.username).toBe('alice'); + }); + + it('leaves non-JSON string bodies untouched (e.g. form-encoded)', () => { + const stepLog = buildHttpRequestStepLog({ + input: { ...baseInput, body: 'name=alice&topic=hello' }, + output: baseOutput, + durationMs: 1, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + expect(stepLog.details.request.body).toBe('name=alice&topic=hello'); + }); + + it('redacts sensitive keys when output.error is a structured object', () => { + const stepLog = buildHttpRequestStepLog({ + input: baseInput, + output: { + success: false, + message: 'failed', + error: { + code: 'invalid_client', + client_secret: 'leaked', + } as unknown as string, + status: 401, + }, + durationMs: 1, + }); + + if (stepLog.details.type !== 'HTTP_REQUEST') { + throw new Error('Expected HTTP_REQUEST details'); + } + + const parsed = JSON.parse(stepLog.details.error ?? '{}'); + + expect(parsed.client_secret).toBe('[redacted]'); + expect(parsed.code).toBe('invalid_client'); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/utils/build-http-request-step-log.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/utils/build-http-request-step-log.util.ts new file mode 100644 index 0000000000..75a3509365 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/utils/build-http-request-step-log.util.ts @@ -0,0 +1,276 @@ +import { type WorkflowRunStepLog } from 'twenty-shared/workflow'; + +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { type WorkflowHttpRequestActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type'; +import { truncateStringToUtf8ByteBudget } from 'src/utils/truncate-string-to-utf8-byte-budget.util'; + +const MAX_BODY_BYTES = 32_000; +const REDACTION_SENTINEL = '[redacted]'; + +const SENSITIVE_HEADER_NAMES = new Set([ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'x-csrf-token', + 'x-amz-security-token', + 'x-goog-api-key', + 'api-key', +]); + +const SENSITIVE_URL_PARAM_NAMES = new Set([ + 'api_key', + 'apikey', + 'api-key', + 'token', + 'access_token', + 'refresh_token', + 'id_token', + 'auth', + 'auth_token', + 'authentication', + 'secret', + 'client_secret', + 'private_key', + 'key', + 'sig', + 'signature', + 'password', + 'passwd', + 'pwd', +]); + +const SENSITIVE_BODY_KEY_REGEX = + /^(password|passwd|pwd|.*_?token|.*_?secret|authorization|api[_-]?key|private[_-]?key|client[_-]?secret|x-?api-?key|x-?auth-?token|access[_-]?key)$/i; + +const isSensitiveHeader = (name: string): boolean => + SENSITIVE_HEADER_NAMES.has(name.toLowerCase()); + +const isSensitiveUrlParam = (name: string): boolean => + SENSITIVE_URL_PARAM_NAMES.has(name.toLowerCase()); + +const isSensitiveBodyKey = (name: string): boolean => + SENSITIVE_BODY_KEY_REGEX.test(name); + +const redactHeaders = ( + headers: Record | undefined, +): Record => { + if (!headers) { + return {}; + } + + const redacted: Record = {}; + + for (const [name, value] of Object.entries(headers)) { + if (isSensitiveHeader(name)) { + redacted[name] = REDACTION_SENTINEL; + continue; + } + + redacted[name] = + typeof value === 'string' ? value : (JSON.stringify(value) ?? ''); + } + + return redacted; +}; + +const URL_QUERY_PARAM_REGEX = /([?&])([^=&#]+)=([^&#]*)/g; + +const safeDecodeUriComponent = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const redactUrlQueryStringFallback = (rawUrl: string): string => + rawUrl.replace(URL_QUERY_PARAM_REGEX, (match, prefix, name) => { + if (isSensitiveUrlParam(safeDecodeUriComponent(name))) { + return `${prefix}${name}=${REDACTION_SENTINEL}`; + } + + return match; + }); + +const redactUrl = (rawUrl: string): string => { + let parsed: URL; + + try { + parsed = new URL(rawUrl); + } catch { + return redactUrlQueryStringFallback(rawUrl); + } + + let didRedact = false; + + for (const paramName of [...parsed.searchParams.keys()]) { + if (isSensitiveUrlParam(paramName)) { + parsed.searchParams.set(paramName, REDACTION_SENTINEL); + didRedact = true; + } + } + + return didRedact ? parsed.toString() : rawUrl; +}; + +const redactSensitiveBodyKeysDeep = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(redactSensitiveBodyKeysDeep); + } + + if (value !== null && typeof value === 'object') { + const redacted: Record = {}; + + for (const [key, nested] of Object.entries(value)) { + if (isSensitiveBodyKey(key)) { + redacted[key] = REDACTION_SENTINEL; + continue; + } + + redacted[key] = redactSensitiveBodyKeysDeep(nested); + } + + return redacted; + } + + return value; +}; + +const redactJsonBodyString = (body: string): string => { + if (body.length === 0) { + return body; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(body); + } catch { + return body; + } + + if (parsed === null || typeof parsed !== 'object') { + return body; + } + + const redactedTree = redactSensitiveBodyKeysDeep(parsed); + + try { + return JSON.stringify(redactedTree); + } catch { + return body; + } +}; + +const redactErrorValue = (error: unknown): string | undefined => { + if (error === undefined || error === null) { + return undefined; + } + + if (typeof error === 'string') { + return redactJsonBodyString(error); + } + + try { + return JSON.stringify(redactSensitiveBodyKeysDeep(error)); + } catch { + return String(error); + } +}; + +const stringifyBody = (body: unknown): string | undefined => { + if (body === undefined || body === null) { + return undefined; + } + + if (typeof body === 'string') { + return body; + } + + if (typeof body === 'object') { + try { + const redactedTree = redactSensitiveBodyKeysDeep(body); + + return JSON.stringify(redactedTree); + } catch { + return String(body); + } + } + + return String(body); +}; + +type SerializedBody = { + body: string | undefined; + bodyBytes: number | undefined; + bodyTruncated: boolean; +}; + +const serializeBody = (body: unknown): SerializedBody => { + const serialized = stringifyBody(body); + + if (serialized === undefined) { + return { body: undefined, bodyBytes: undefined, bodyTruncated: false }; + } + + const redacted = + typeof body === 'string' ? redactJsonBodyString(serialized) : serialized; + + const { value, originalBytes, truncated } = truncateStringToUtf8ByteBudget( + redacted, + MAX_BODY_BYTES, + ); + + return { body: value, bodyBytes: originalBytes, bodyTruncated: truncated }; +}; + +export const buildHttpRequestStepLog = ({ + input, + output, + durationMs, +}: { + input: WorkflowHttpRequestActionInput; + output: ToolOutput; + durationMs: number; +}): WorkflowRunStepLog => { + const requestBody = serializeBody(input.body); + const responseBody = serializeBody(output.result); + + const hasResponseMetadata = + typeof output.status === 'number' || + (output.headers !== undefined && Object.keys(output.headers).length > 0) || + responseBody.body !== undefined; + + const response = hasResponseMetadata + ? { + status: output.status ?? 0, + statusText: output.statusText, + headers: redactHeaders(output.headers), + body: responseBody.body, + bodyBytes: responseBody.bodyBytes, + bodyTruncated: responseBody.bodyTruncated, + } + : undefined; + + return { + details: { + type: 'HTTP_REQUEST', + request: { + method: input.method, + url: redactUrl(input.url), + headers: redactHeaders(input.headers), + body: requestBody.body, + bodyBytes: requestBody.bodyBytes, + bodyTruncated: requestBody.bodyTruncated, + }, + response, + error: redactErrorValue(output.error), + durationMs, + }, + entries: [], + sizeBytes: 0, + }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/draft-email.workflow-action.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/draft-email.workflow-action.spec.ts new file mode 100644 index 0000000000..b0b56732b5 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/draft-email.workflow-action.spec.ts @@ -0,0 +1,139 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool'; +import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action'; +import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; +import { + type WorkflowAction, + WorkflowActionType, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; + +jest.mock( + 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util', + () => ({ + renderRichTextToHtml: jest.fn().mockResolvedValue('

rendered html

'), + }), +); + +const baseSettings: WorkflowActionSettings = { + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + input: {}, +}; + +const buildDraftEmailStep = (input: Record): WorkflowAction => + ({ + id: 'step-1', + type: WorkflowActionType.DRAFT_EMAIL, + name: 'Draft Email', + valid: true, + settings: { ...baseSettings, input }, + }) as WorkflowAction; + +describe('DraftEmailWorkflowAction', () => { + let action: DraftEmailWorkflowAction; + let mockDraftEmailTool: jest.Mocked>; + let mockSetStepLog: jest.Mock; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockDraftEmailTool = { + execute: jest.fn().mockResolvedValue({ + result: { success: true }, + error: undefined, + }), + }; + mockSetStepLog = jest.fn(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DraftEmailWorkflowAction, + { provide: DraftEmailTool, useValue: mockDraftEmailTool }, + { + provide: WorkflowRunStepLogWorkspaceService, + useValue: { setStepLog: mockSetStepLog }, + }, + ], + }).compile(); + + action = module.get(DraftEmailWorkflowAction); + }); + + it('runs the draft email tool and resolves variables in the body', async () => { + await action.execute({ + currentStepId: 'step-1', + steps: [ + buildDraftEmailStep({ + connectedAccountId: 'account-1', + recipients: { to: 'test@example.com' }, + subject: 'Draft Test', + body: '{{trigger.name}}', + }), + ], + context: { trigger: { name: 'John' } }, + runInfo: { workspaceId: 'workspace-1', workflowRunId: 'run-1' }, + }); + + expect(mockDraftEmailTool.execute).toHaveBeenCalledWith( + expect.objectContaining({ body: 'John' }), + expect.objectContaining({ workspaceId: 'workspace-1' }), + ); + }); + + it('persists a step log tagged with the DRAFT mode', async () => { + await action.execute({ + currentStepId: 'step-1', + steps: [ + buildDraftEmailStep({ + connectedAccountId: 'account-1', + recipients: { to: 'test@example.com' }, + subject: 'Draft Test', + body: 'hello', + }), + ], + context: {}, + runInfo: { workspaceId: 'workspace-1', workflowRunId: 'run-1' }, + }); + + expect(mockSetStepLog).toHaveBeenCalledWith( + expect.objectContaining({ + workflowRunId: 'run-1', + workspaceId: 'workspace-1', + stepId: 'step-1', + stepLog: expect.objectContaining({ + details: expect.objectContaining({ + type: 'EMAIL', + mode: 'DRAFT', + }), + }), + }), + ); + }); + + it('throws when the current step is not a draft-email action', async () => { + await expect( + action.execute({ + currentStepId: 'step-1', + steps: [ + { + ...buildDraftEmailStep({ + connectedAccountId: 'account-1', + recipients: { to: 'test@example.com' }, + subject: 'Wrong type', + }), + type: WorkflowActionType.SEND_EMAIL, + } as WorkflowAction, + ], + context: {}, + runInfo: { workspaceId: 'workspace-1', workflowRunId: 'run-1' }, + }), + ).rejects.toThrow('Step is not a draft-email action'); + + expect(mockDraftEmailTool.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/__tests__/tool-executor-workflow-action.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/send-email.workflow-action.spec.ts similarity index 72% rename from packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/__tests__/tool-executor-workflow-action.spec.ts rename to packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/send-email.workflow-action.spec.ts index 62658b3a39..1db264897d 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/__tests__/tool-executor-workflow-action.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/send-email.workflow-action.spec.ts @@ -1,14 +1,13 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool'; -import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; -import { ToolExecutorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action'; +import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action'; import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; import { type WorkflowAction, WorkflowActionType, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; jest.mock( 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util', @@ -36,53 +35,47 @@ const emailInput = { subject: 'Test', }; -const buildEmailStep = ( - type: 'SEND_EMAIL' | 'DRAFT_EMAIL', - input: Record, -): WorkflowAction => +const buildSendEmailStep = (input: Record): WorkflowAction => ({ id: 'step-1', - type: WorkflowActionType[type], - name: type === 'SEND_EMAIL' ? 'Send Email' : 'Draft Email', + type: WorkflowActionType.SEND_EMAIL, + name: 'Send Email', valid: true, settings: { ...baseSettings, input }, }) as WorkflowAction; -describe('ToolExecutorWorkflowAction', () => { - let action: ToolExecutorWorkflowAction; +describe('SendEmailWorkflowAction', () => { + let action: SendEmailWorkflowAction; let mockSendEmailTool: jest.Mocked>; - let mockDraftEmailTool: jest.Mocked>; beforeEach(async () => { jest.clearAllMocks(); - const toolResult = { - result: { success: true }, - error: undefined, + mockSendEmailTool = { + execute: jest.fn().mockResolvedValue({ + result: { success: true }, + error: undefined, + }), }; - mockSendEmailTool = { execute: jest.fn().mockResolvedValue(toolResult) }; - mockDraftEmailTool = { execute: jest.fn().mockResolvedValue(toolResult) }; - const module: TestingModule = await Test.createTestingModule({ providers: [ - ToolExecutorWorkflowAction, - { provide: HttpTool, useValue: { execute: jest.fn() } }, + SendEmailWorkflowAction, { provide: SendEmailTool, useValue: mockSendEmailTool }, - { provide: DraftEmailTool, useValue: mockDraftEmailTool }, + { + provide: WorkflowRunStepLogWorkspaceService, + useValue: { setStepLog: jest.fn() }, + }, ], }).compile(); - action = module.get(ToolExecutorWorkflowAction); + action = module.get(SendEmailWorkflowAction); }); - const executeWithBody = ( - body: string | undefined, - type: 'SEND_EMAIL' | 'DRAFT_EMAIL' = 'SEND_EMAIL', - ) => + const executeWithBody = (body: string | undefined) => action.execute({ currentStepId: 'step-1', - steps: [buildEmailStep(type, { ...emailInput, body })], + steps: [buildSendEmailStep({ ...emailInput, body })], context: { trigger: { name: 'John', @@ -178,15 +171,25 @@ describe('ToolExecutorWorkflowAction', () => { expect(renderRichTextToHtml).not.toHaveBeenCalled(); expect(mockSendEmailTool.execute).toHaveBeenCalled(); }); + }); - it('should apply the same body handling for DRAFT_EMAIL', async () => { - await executeWithBody('{{trigger.name}}', 'DRAFT_EMAIL'); + describe('step type guard', () => { + it('throws when the current step is not a send-email action', async () => { + await expect( + action.execute({ + currentStepId: 'step-1', + steps: [ + { + ...buildSendEmailStep({ ...emailInput, body: 'hi' }), + type: WorkflowActionType.DRAFT_EMAIL, + } as WorkflowAction, + ], + context: {}, + runInfo: { workspaceId: 'workspace-1', workflowRunId: 'run-1' }, + }), + ).rejects.toThrow('Step is not a send-email action'); - expect(renderRichTextToHtml).not.toHaveBeenCalled(); - expect(mockDraftEmailTool.execute).toHaveBeenCalledWith( - expect.objectContaining({ body: 'John' }), - expect.any(Object), - ); + expect(mockSendEmailTool.execute).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action.ts new file mode 100644 index 0000000000..a6fdd6b0da --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; + +import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool'; +import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; +import { + WorkflowStepExecutorException, + WorkflowStepExecutorExceptionCode, +} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; +import { EmailWorkflowActionBase } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/email-workflow-action.base'; +import { isWorkflowDraftEmailAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard'; +import { type EmailStepLogMode } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/build-email-step-log.util'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; + +@Injectable() +export class DraftEmailWorkflowAction extends EmailWorkflowActionBase { + constructor( + private readonly draftEmailTool: DraftEmailTool, + workflowRunStepLogService: WorkflowRunStepLogWorkspaceService, + ) { + super(DraftEmailWorkflowAction.name, workflowRunStepLogService); + } + + protected getTool(): Tool { + return this.draftEmailTool; + } + + protected getMode(): EmailStepLogMode { + return 'DRAFT'; + } + + protected assertStep(step: WorkflowAction): void { + if (!isWorkflowDraftEmailAction(step)) { + throw new WorkflowStepExecutorException( + 'Step is not a draft-email action', + WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE, + ); + } + } +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/email-workflow-action.base.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/email-workflow-action.base.ts new file mode 100644 index 0000000000..5ab586544b --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/email-workflow-action.base.ts @@ -0,0 +1,46 @@ +import { type WorkflowRunStepLog } from 'twenty-shared/workflow'; + +import { isDefined } from 'twenty-shared/utils'; + +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { type WorkflowSendEmailActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-input.type'; +import { + buildEmailStepLog, + type EmailStepLogMode, +} from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/build-email-step-log.util'; +import { resolveEmailBody } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/resolve-email-body.util'; +import { ToolBackedWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-backed/tool-backed.workflow-action'; + +export abstract class EmailWorkflowActionBase extends ToolBackedWorkflowAction { + protected abstract getMode(): EmailStepLogMode; + + protected override async preprocessInput( + rawInput: WorkflowSendEmailActionInput, + context: Record, + ): Promise { + if (!isDefined(rawInput.body)) { + return rawInput; + } + + const renderedBody = await resolveEmailBody(rawInput.body, context); + + return { ...rawInput, body: renderedBody }; + } + + protected buildStepLog({ + input, + output, + durationMs, + }: { + input: WorkflowSendEmailActionInput; + output: ToolOutput; + durationMs: number; + }): WorkflowRunStepLog { + return buildEmailStepLog({ + mode: this.getMode(), + input, + output, + durationMs, + }); + } +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/mail-sender-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/mail-sender-action.module.ts new file mode 100644 index 0000000000..c1071f6af8 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/mail-sender-action.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; +import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action'; +import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action'; +import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module'; + +@Module({ + imports: [ToolModule, WorkflowRunModule], + providers: [SendEmailWorkflowAction, DraftEmailWorkflowAction], + exports: [SendEmailWorkflowAction, DraftEmailWorkflowAction], +}) +export class MailSenderActionModule {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action.ts new file mode 100644 index 0000000000..6cf4f0b715 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; + +import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool'; +import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; +import { + WorkflowStepExecutorException, + WorkflowStepExecutorExceptionCode, +} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; +import { EmailWorkflowActionBase } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/email-workflow-action.base'; +import { isWorkflowSendEmailAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-send-email-action.guard'; +import { type EmailStepLogMode } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/build-email-step-log.util'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; + +@Injectable() +export class SendEmailWorkflowAction extends EmailWorkflowActionBase { + constructor( + private readonly sendEmailTool: SendEmailTool, + workflowRunStepLogService: WorkflowRunStepLogWorkspaceService, + ) { + super(SendEmailWorkflowAction.name, workflowRunStepLogService); + } + + protected getTool(): Tool { + return this.sendEmailTool; + } + + protected getMode(): EmailStepLogMode { + return 'SEND'; + } + + protected assertStep(step: WorkflowAction): void { + if (!isWorkflowSendEmailAction(step)) { + throw new WorkflowStepExecutorException( + 'Step is not a send-email action', + WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE, + ); + } + } +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/__tests__/build-email-step-log.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/__tests__/build-email-step-log.util.spec.ts new file mode 100644 index 0000000000..a82a8ff60a --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/__tests__/build-email-step-log.util.spec.ts @@ -0,0 +1,162 @@ +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { type WorkflowSendEmailActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-input.type'; +import { buildEmailStepLog } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/build-email-step-log.util'; + +const baseInput: WorkflowSendEmailActionInput = { + connectedAccountId: 'account-1', + recipients: { to: 'alice@example.com, bob@example.com' }, + subject: 'Welcome', + body: '

Hello

', +}; + +const successOutput: ToolOutput = { + success: true, + message: 'Email sent successfully to Alice', + result: { + recipients: ['alice@example.com', 'bob@example.com'], + ccRecipients: [], + bccRecipients: [], + subject: 'Welcome', + connectedAccountId: 'account-1', + attachmentCount: 0, + }, +}; + +describe('buildEmailStepLog', () => { + it('builds a SUCCESS email log preferring parsed recipients from the tool output', () => { + const stepLog = buildEmailStepLog({ + mode: 'SEND', + input: baseInput, + output: successOutput, + durationMs: 120, + }); + + if (stepLog.details.type !== 'EMAIL') { + throw new Error('Expected EMAIL details'); + } + + expect(stepLog.details.mode).toBe('SEND'); + expect(stepLog.details.status).toBe('SUCCESS'); + expect(stepLog.details.recipients.to).toEqual([ + 'alice@example.com', + 'bob@example.com', + ]); + expect(stepLog.details.recipients.cc).toBeUndefined(); + expect(stepLog.details.subject).toBe('Welcome'); + expect(stepLog.details.attachmentCount).toBe(0); + expect(stepLog.details.durationMs).toBe(120); + }); + + it('falls back to splitting the comma-separated input when the tool output has no parsed recipients', () => { + const stepLog = buildEmailStepLog({ + mode: 'DRAFT', + input: { + ...baseInput, + recipients: { + to: 'alice@example.com,bob@example.com ; carol@example.com', + cc: 'dan@example.com', + }, + }, + output: { + success: false, + message: 'Failed to create draft', + error: 'Connected account expired', + }, + durationMs: 5, + }); + + if (stepLog.details.type !== 'EMAIL') { + throw new Error('Expected EMAIL details'); + } + + expect(stepLog.details.mode).toBe('DRAFT'); + expect(stepLog.details.status).toBe('ERROR'); + expect(stepLog.details.recipients.to).toEqual([ + 'alice@example.com', + 'bob@example.com', + 'carol@example.com', + ]); + expect(stepLog.details.recipients.cc).toEqual(['dan@example.com']); + expect(stepLog.details.error).toBe('Connected account expired'); + }); + + it('truncates oversized body previews and reports original byte size', () => { + const longBody = `

${'x'.repeat(20_000)}

`; + + const stepLog = buildEmailStepLog({ + mode: 'SEND', + input: { ...baseInput, body: longBody }, + output: successOutput, + durationMs: 10, + }); + + if (stepLog.details.type !== 'EMAIL') { + throw new Error('Expected EMAIL details'); + } + + expect(stepLog.details.bodyTruncated).toBe(true); + expect(stepLog.details.bodyPreview).toContain('truncated'); + expect(stepLog.details.bodyBytes).toBeGreaterThan(20_000); + }); + + it('prefers the sanitized HTML body from the tool output over the raw input body', () => { + const stepLog = buildEmailStepLog({ + mode: 'SEND', + input: { + ...baseInput, + body: '

Hello

', + }, + output: { + ...successOutput, + result: { + ...(successOutput.result as object), + sanitizedHtmlBody: '

Hello

', + plainTextBody: 'Hello', + }, + }, + durationMs: 10, + }); + + if (stepLog.details.type !== 'EMAIL') { + throw new Error('Expected EMAIL details'); + } + + expect(stepLog.details.bodyPreview).toBe('

Hello

'); + expect(stepLog.details.bodyPreview).not.toContain('