Provide additional logsobservability to workflow runs (per node) (#21142)
Surfaces per-step "Logs" tabs in the workflow run side panel so users can see what each step actually did (model + tokens + tool calls for AI, console output for serverless functions, request/response for HTTP, recipients/body for Email). <img width="546" height="501" alt="ai_agent_without_websearch" src="https://github.com/user-attachments/assets/c6ca3518-9489-4484-a570-3d0569ff3b03" /> ## Storage - New `stepLogs` JSONB column on the `workflowRun` workspace entity, typed as `Record<string, WorkflowRunStepLog>` (keyed by step id). - Schema lives in `twenty-shared`: `workflowRunStepLogSchema` with a discriminated `details.type` union for `AI_AGENT | CODE | HTTP_REQUEST | EMAIL` — frontends and backends consume the same Zod-inferred type. - Field is added to existing workspaces via a workspace upgrade command (`2-9 add-workflow-run-step-logs-field`); the standard-object metadata declares it for new workspaces. - Writes happen atomically per step in `WorkflowRunStepLogWorkspaceService.setStepLog` using `jsonb_set`. That lets concurrent steps in the same run write their own keys without contending with the existing lock around `workflowRun.state`. - Per-step payload is hard-capped at 256 KB; anything larger is dropped with a `logger.warn`, so a pathological tool call can never bloat a row. See below for more information. ## How logs are produced **Aalmost everything was already being collected; this PR mostly persists and renders it.** - **AI agent** — `AgentAsyncExecutorService` already tracked token usage, model id, native web-search count, and the AI SDK's `steps[]`. We map those into the log via `mapAiStepsToToolCallLogs` (`searchVector` stripped from record outputs, per-call input/output capped at 32/64 KB, max 200 tool calls per step). The only new measurement is a wall-clock `durationMs` taken around `executeAgent`, and we now fold native web-search cost into the displayed `totalCostInDollars` (it was already billed, just not shown). - **Code / serverless function** — reuses the `console.log` output the function runner already returns (`logsByLevel`); `build-code-step-log.util` only repackages it. - **HTTP request** — built from the action's existing input/output via `build-http-request-step-log.util`. No new signals collected. - **Email (send / draft)** — added `sanitizedHtmlBody` + `plainTextBody` to the existing tool outputs (a small additive change), then `build-email-step-log.util` consumes them. No additional AI inference or external calls are made for logging — the cost is a small CPU overhead per step plus the JSONB write. ## Security The log surface intentionally shows whatever the workflow touched, which made redaction and sanitization the main design concern. - **HTTP — secrets in headers**: existing `SENSITIVE_HEADER_NAMES` set (Authorization, Cookie, …) replaced with `[redacted]` in both request and response. - **HTTP — secrets in URLs**: `SENSITIVE_URL_PARAM_NAMES` (e.g. `api_key`, `token`, `access_token`) replaced in the query string via `URL`-based parsing. - **HTTP — secrets in bodies**: `SENSITIVE_BODY_KEY_REGEX` deep-walks JSON request/response bodies (object input or stringified JSON) and redacts matching keys. Applied to the `error` field too, since transport-layer errors sometimes embed structured payloads. - **Email — XSS risk in body preview**: tool outputs now expose a server-side `sanitizedHtmlBody`; the log builder prefers it over the raw user-authored `input.body`, with `plainTextBody` as a second fallback. The original raw body is only used if sanitization didn't happen (e.g. tool failed before composing). - **AI — internal/noisy data**: `searchVector` (Postgres tsvector strings) is stripped from record outputs returned by Twenty tools to avoid leaking internal full-text-search payloads. - **DB bloat / runaway agents**: 256 KB per-step cap + 32 KB / 64 KB per-tool-call input/output cap + 200 tool calls per step. <img width="547" height="307" alt="logic_function" src="https://github.com/user-attachments/assets/dd4a3d16-67f2-434b-95b3-bdcaf9ed053d" /> ## More details on Log size & truncation Logs are stored in `workflowRun.stepLogs` (JSONB), keyed by `stepId`. ### Per-step cap Each step's log is hard-capped at **256 KB** (`MAX_STEP_LOG_BYTES` in `WorkflowRunStepLogWorkspaceService.setStepLog`). For ~99% of workflows this is roomy — typical real-world sizes: - Code / serverless function: 1–20 KB - HTTP request: 5–70 KB - Email: 5–30 KB - AI agent (a handful of tool calls): 5–50 KB ### Two layers of bounding 1. **Per-field truncation** in each builder (before writing): - **Code**: ≤ 500 entries, ≤ 4 KB per message, ≤ 8 KB stack trace - **HTTP**: ≤ 32 KB per body (request + response), UTF-8 byte-aware - **Email**: ≤ 8 KB body preview, UTF-8 byte-aware - **AI agent**: ≤ 32 KB tool input, ≤ 64 KB tool output, ≤ 200 tool calls/step 2. **Global per-step safety net** at write time: if the assembled `stepLog` still exceeds 256 KB, the write is **dropped entirely** with a `logger.warn`. The workflow itself keeps running unaffected. ### What this means in practice - **Safe**: workflow execution, step results, downstream steps — never blocked by log size. - **Safe**: iterators (each iteration overwrites the previous log for that `stepId`, so they can't accumulate). - **Safe**: step retries (same `stepId` is overwritten, not appended). - **Possible**: an AI agent step with many large tool outputs (e.g., 50+ heavy `web_search` calls) can exceed 256 KB → the **entire** step's log is dropped, side panel shows "No logs were recorded for this step". The user has no explicit signal that the log was dropped due to size (only server-side warn). - **Possible** (theoretical): a workflow with hundreds of distinct steps could push the row toward Postgres's internal ~256 MB jsonb limit. Beyond that, individual `jsonb_set` writes would error and be swallowed by the action's try/catch — workflow still completes. ### Possible future hardening (not in this PR) - Replace "drop entire log" with a stub that preserves the summary card (cost, duration, status) and marks `truncated.reason = 'size_cap'`. - Surface size-drops in the UI (similar to the existing `<StyledTruncatedNotice>`). - Emit a metric so dropped logs are observable in dashboards.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+30
-2
@@ -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<TabId>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -162,6 +183,13 @@ export const SidePanelWorkflowRunViewStepContent = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isStepLogsEnabled && activeTabId === WorkflowRunTabId.LOGS ? (
|
||||
<WorkflowRunStepLogsDetail
|
||||
key={workflowSelectedNode}
|
||||
stepId={workflowSelectedNode}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<WorkflowIteratorSubStepSwitcher stepId={workflowSelectedNode} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const MONOSPACE_FONT_FAMILY = `'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace`;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<WorkflowRun, '__typename' | 'id' | 'stepLogs'>
|
||||
>({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
|
||||
objectRecordId: workflowRunId,
|
||||
recordGqlFields: { id: true, stepLogs: true },
|
||||
skip,
|
||||
});
|
||||
|
||||
return record?.stepLogs?.[stepId];
|
||||
};
|
||||
+220
@@ -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 (
|
||||
<>
|
||||
<StyledSummaryCard>
|
||||
<StyledSummaryHeader>
|
||||
<StyledHeaderLeft>
|
||||
<IconBrain size={16} />
|
||||
<StyledTitle>{t`AI agent run`}</StyledTitle>
|
||||
</StyledHeaderLeft>
|
||||
<StyledBadgeGroup>
|
||||
<StyledModelBadge>{modelId}</StyledModelBadge>
|
||||
</StyledBadgeGroup>
|
||||
</StyledSummaryHeader>
|
||||
|
||||
<StyledMetricsRow>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconCpu size={12} />
|
||||
{t`Tokens`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>
|
||||
{formatTokenCount(usage.totalTokens)}
|
||||
</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconCoins size={12} />
|
||||
{t`Cost`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>
|
||||
{formatCost(cost.totalCostInDollars)}
|
||||
</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconTool size={12} />
|
||||
{t`Tool calls`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>{toolCalls.length}</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
{nativeWebSearchCallCount > 0 && (
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconWorld size={12} />
|
||||
{t`Web searches`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>{nativeWebSearchCallCount}</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
)}
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconClock size={12} />
|
||||
{t`Duration`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>{formatDuration(durationMs)}</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
</StyledMetricsRow>
|
||||
</StyledSummaryCard>
|
||||
|
||||
{usageRows.length > 0 && (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Token usage`}</StyledSectionTitle>
|
||||
<StyledUsageGrid>
|
||||
{usageRows.map((row) => (
|
||||
<Fragment key={row.label}>
|
||||
<StyledUsageLabel>{row.label}</StyledUsageLabel>
|
||||
<StyledUsageValue>
|
||||
{formatNumber(row.value ?? 0)}
|
||||
</StyledUsageValue>
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledUsageGrid>
|
||||
</StyledSection>
|
||||
)}
|
||||
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>
|
||||
{toolCalls.length > 0
|
||||
? t`Tool calls (${toolCalls.length})`
|
||||
: t`Tool calls`}
|
||||
</StyledSectionTitle>
|
||||
{toolCalls.length === 0 ? (
|
||||
<StyledEmptyHint>{t`No tools were called`}</StyledEmptyHint>
|
||||
) : (
|
||||
<StyledToolList>
|
||||
{toolCalls.map((toolCall) => (
|
||||
<WorkflowRunStepLogsToolCallRow
|
||||
key={toolCall.toolCallId}
|
||||
toolCall={toolCall}
|
||||
/>
|
||||
))}
|
||||
</StyledToolList>
|
||||
)}
|
||||
</StyledSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+111
@@ -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<WorkflowRunStepLog['details'], { type: 'CODE' }>;
|
||||
|
||||
export const WorkflowRunStepLogsCodeDetail = ({
|
||||
details,
|
||||
}: {
|
||||
details: CodeDetails;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const isSuccess = details.status === 'SUCCESS';
|
||||
const StatusIcon = isSuccess ? IconCheck : IconAlertTriangle;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledSummaryCard>
|
||||
<StyledSummaryHeader>
|
||||
<StyledHeaderLeft>
|
||||
<IconTerminal size={16} />
|
||||
<StyledTitle>{t`Function run`}</StyledTitle>
|
||||
</StyledHeaderLeft>
|
||||
<StyledStatusBadge isSuccess={isSuccess}>
|
||||
<StatusIcon size={12} />
|
||||
{isSuccess ? t`Success` : t`Error`}
|
||||
</StyledStatusBadge>
|
||||
</StyledSummaryHeader>
|
||||
<StyledMetricsRow>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconClock size={12} />
|
||||
{t`Duration`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>
|
||||
{formatDuration(details.durationMs)}
|
||||
</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
</StyledMetricsRow>
|
||||
</StyledSummaryCard>
|
||||
|
||||
{isDefined(details.error) && details.error !== null && (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Error`}</StyledSectionTitle>
|
||||
<StyledErrorCard>
|
||||
<StyledErrorHeader>
|
||||
<IconAlertTriangle size={14} />
|
||||
{details.error.type}
|
||||
</StyledErrorHeader>
|
||||
<StyledErrorMessageText>
|
||||
{details.error.message}
|
||||
</StyledErrorMessageText>
|
||||
{isDefined(details.error.stackTrace) &&
|
||||
details.error.stackTrace.length > 0 && (
|
||||
<StyledStackTrace>{details.error.stackTrace}</StyledStackTrace>
|
||||
)}
|
||||
</StyledErrorCard>
|
||||
</StyledSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+138
@@ -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 (
|
||||
<StyledRoot>
|
||||
<StyledEmptyState>
|
||||
<IconInfoCircle size={20} />
|
||||
<div>{t`No logs were recorded for this step.`}</div>
|
||||
</StyledEmptyState>
|
||||
</StyledRoot>
|
||||
);
|
||||
}
|
||||
|
||||
const parseResult = workflowRunStepLogSchema.safeParse(rawStepLog);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return (
|
||||
<StyledRoot>
|
||||
<JsonTree
|
||||
value={rawStepLog as JsonValue}
|
||||
shouldExpandNodeInitially={isTwoFirstDepths}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
</StyledRoot>
|
||||
);
|
||||
}
|
||||
|
||||
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 <WorkflowRunStepLogsAiAgentDetail details={stepLog.details} />;
|
||||
case 'CODE':
|
||||
return <WorkflowRunStepLogsCodeDetail details={stepLog.details} />;
|
||||
case 'HTTP_REQUEST':
|
||||
return (
|
||||
<WorkflowRunStepLogsHttpRequestDetail details={stepLog.details} />
|
||||
);
|
||||
case 'EMAIL':
|
||||
return <WorkflowRunStepLogsEmailDetail details={stepLog.details} />;
|
||||
default:
|
||||
return (
|
||||
<JsonTree
|
||||
value={stepLog.details as JsonValue}
|
||||
shouldExpandNodeInitially={isTwoFirstDepths}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledRoot>
|
||||
{renderDetails()}
|
||||
|
||||
<WorkflowRunStepLogsEntries
|
||||
entries={stepLog.entries}
|
||||
onlyLatestIteration={isInsideIteratorLoop}
|
||||
/>
|
||||
|
||||
{isDefined(stepLog.truncated) && (
|
||||
<StyledTruncatedNotice>
|
||||
{t`Some log data was dropped to fit the size limit (${stepLog.truncated.droppedEntries} entries, ${stepLog.truncated.droppedBytes} bytes).`}
|
||||
</StyledTruncatedNotice>
|
||||
)}
|
||||
</StyledRoot>
|
||||
);
|
||||
};
|
||||
+211
@@ -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<WorkflowRunStepLog['details'], { type: 'EMAIL' }>;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<StyledSummaryCard>
|
||||
<StyledSummaryHeader>
|
||||
<StyledHeaderLeft>
|
||||
<IconMail size={16} />
|
||||
<StyledTitle>{titleText}</StyledTitle>
|
||||
</StyledHeaderLeft>
|
||||
<StyledBadgeGroup>
|
||||
<StyledModeBadge>{details.mode}</StyledModeBadge>
|
||||
<StyledStatusBadge isSuccess={isSuccess}>
|
||||
<StatusIcon size={12} />
|
||||
{statusLabel}
|
||||
</StyledStatusBadge>
|
||||
</StyledBadgeGroup>
|
||||
</StyledSummaryHeader>
|
||||
|
||||
{isDefined(details.subject) && details.subject.length > 0 && (
|
||||
<StyledSubject>{details.subject}</StyledSubject>
|
||||
)}
|
||||
|
||||
<StyledMetricsRow>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconClock size={12} />
|
||||
{t`Duration`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>
|
||||
{formatDuration(details.durationMs)}
|
||||
</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
{isDefined(details.attachmentCount) &&
|
||||
details.attachmentCount > 0 && (
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconPaperclip size={12} />
|
||||
{t`Attachments`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>{details.attachmentCount}</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
)}
|
||||
</StyledMetricsRow>
|
||||
</StyledSummaryCard>
|
||||
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Recipients`}</StyledSectionTitle>
|
||||
<StyledRecipientsCard>
|
||||
<StyledRecipientLabel>{t`To`}</StyledRecipientLabel>
|
||||
<StyledRecipientValue>
|
||||
{isNonEmptyArray(details.recipients.to)
|
||||
? details.recipients.to.join(', ')
|
||||
: t`—`}
|
||||
</StyledRecipientValue>
|
||||
{isNonEmptyArray(details.recipients.cc) && (
|
||||
<>
|
||||
<StyledRecipientLabel>{t`Cc`}</StyledRecipientLabel>
|
||||
<StyledRecipientValue>
|
||||
{details.recipients.cc.join(', ')}
|
||||
</StyledRecipientValue>
|
||||
</>
|
||||
)}
|
||||
{isNonEmptyArray(details.recipients.bcc) && (
|
||||
<>
|
||||
<StyledRecipientLabel>{t`Bcc`}</StyledRecipientLabel>
|
||||
<StyledRecipientValue>
|
||||
{details.recipients.bcc.join(', ')}
|
||||
</StyledRecipientValue>
|
||||
</>
|
||||
)}
|
||||
</StyledRecipientsCard>
|
||||
</StyledSection>
|
||||
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Body`}</StyledSectionTitle>
|
||||
{isDefined(details.bodyPreview) && details.bodyPreview.length > 0 ? (
|
||||
<>
|
||||
<StyledBodyContainer>
|
||||
<StyledBodyPre>{details.bodyPreview}</StyledBodyPre>
|
||||
</StyledBodyContainer>
|
||||
{(isDefined(details.bodyBytes) || details.bodyTruncated) && (
|
||||
<StyledBodyMeta>
|
||||
{isDefined(details.bodyBytes) && formatBytes(details.bodyBytes)}
|
||||
{details.bodyTruncated && ' · truncated'}
|
||||
</StyledBodyMeta>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<StyledEmptyHint>{t`No body`}</StyledEmptyHint>
|
||||
)}
|
||||
</StyledSection>
|
||||
|
||||
{!isSuccess && isDefined(details.error) && (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Error`}</StyledSectionTitle>
|
||||
<StyledErrorCard>
|
||||
<StyledErrorMessageText>{details.error}</StyledErrorMessageText>
|
||||
</StyledErrorCard>
|
||||
</StyledSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+113
@@ -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 (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{sectionTitle}</StyledSectionTitle>
|
||||
<StyledEntriesList>
|
||||
{entries.map((entry, index) => (
|
||||
<StyledEntryRow key={`${entry.timestamp}-${index}`}>
|
||||
<StyledTimestamp>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</StyledTimestamp>
|
||||
<StyledLevelBadge level={entry.level}>
|
||||
{entry.level}
|
||||
</StyledLevelBadge>
|
||||
<StyledMessage>{entry.message}</StyledMessage>
|
||||
</StyledEntryRow>
|
||||
))}
|
||||
</StyledEntriesList>
|
||||
</StyledSection>
|
||||
);
|
||||
};
|
||||
+255
@@ -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<string, string> }) => {
|
||||
const entries = Object.entries(headers);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledHeaderTable>
|
||||
{entries.map(([name, value]) => (
|
||||
<Fragment key={name}>
|
||||
<StyledHeaderName>{name}</StyledHeaderName>
|
||||
<StyledHeaderValue>{value}</StyledHeaderValue>
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledHeaderTable>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyBlock = ({
|
||||
body,
|
||||
bodyBytes,
|
||||
bodyTruncated,
|
||||
emptyLabel,
|
||||
}: {
|
||||
body: string | undefined;
|
||||
bodyBytes: number | undefined;
|
||||
bodyTruncated: boolean | undefined;
|
||||
emptyLabel: string;
|
||||
}) => {
|
||||
if (!isDefined(body) || body.length === 0) {
|
||||
return <StyledEmptyHint>{emptyLabel}</StyledEmptyHint>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledBodyPre>{body}</StyledBodyPre>
|
||||
{(isDefined(bodyBytes) || bodyTruncated) && (
|
||||
<StyledBodyMeta>
|
||||
{isDefined(bodyBytes) && formatBytes(bodyBytes)}
|
||||
{bodyTruncated && ' · truncated'}
|
||||
</StyledBodyMeta>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<StyledSummaryCard>
|
||||
<StyledSummaryHeader>
|
||||
<StyledHeaderLeft>
|
||||
<IconWorld size={16} />
|
||||
<StyledTitle>{t`HTTP request`}</StyledTitle>
|
||||
</StyledHeaderLeft>
|
||||
<StyledBadgeGroup>
|
||||
<StyledMethodBadge method={request.method}>
|
||||
{request.method}
|
||||
</StyledMethodBadge>
|
||||
{isDefined(response) ? (
|
||||
<StyledStatusBadge isSuccess={isSuccess}>
|
||||
{response.status}
|
||||
{isDefined(response.statusText) &&
|
||||
response.statusText.length > 0
|
||||
? ` ${response.statusText}`
|
||||
: ''}
|
||||
</StyledStatusBadge>
|
||||
) : (
|
||||
<StyledNetworkErrorBadge>
|
||||
<IconAlertTriangle size={12} />
|
||||
{t`Network error`}
|
||||
</StyledNetworkErrorBadge>
|
||||
)}
|
||||
</StyledBadgeGroup>
|
||||
</StyledSummaryHeader>
|
||||
<StyledUrl>{request.url}</StyledUrl>
|
||||
<StyledMetricsRow>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconClock size={12} />
|
||||
{t`Duration`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>{formatDuration(durationMs)}</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
</StyledMetricsRow>
|
||||
</StyledSummaryCard>
|
||||
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>
|
||||
<IconArrowUp size={12} />
|
||||
{t`Request`}
|
||||
</StyledSectionTitle>
|
||||
<HeaderTable headers={request.headers} />
|
||||
<BodyBlock
|
||||
body={request.body}
|
||||
bodyBytes={request.bodyBytes}
|
||||
bodyTruncated={request.bodyTruncated}
|
||||
emptyLabel={t`No request body`}
|
||||
/>
|
||||
</StyledSection>
|
||||
|
||||
{isDefined(response) ? (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>
|
||||
<IconArrowDown size={12} />
|
||||
{t`Response`}
|
||||
</StyledSectionTitle>
|
||||
<HeaderTable headers={response.headers} />
|
||||
<BodyBlock
|
||||
body={response.body}
|
||||
bodyBytes={response.bodyBytes}
|
||||
bodyTruncated={response.bodyTruncated}
|
||||
emptyLabel={t`No response body`}
|
||||
/>
|
||||
</StyledSection>
|
||||
) : (
|
||||
hasNetworkError &&
|
||||
isDefined(error) && (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>
|
||||
<IconAlertTriangle size={12} />
|
||||
{t`Error`}
|
||||
</StyledSectionTitle>
|
||||
<StyledErrorCard>
|
||||
<StyledErrorMessageText>{error}</StyledErrorMessageText>
|
||||
</StyledErrorCard>
|
||||
</StyledSection>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+243
@@ -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<TabType>('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 (
|
||||
<StyledContainer>
|
||||
<StyledToggleButton
|
||||
type="button"
|
||||
isExpandable={isExpandable}
|
||||
onClick={() => isExpandable && setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<StyledLeftContent>
|
||||
<StyledIconContainer>
|
||||
<ToolIcon size={theme.icon.size.sm} />
|
||||
</StyledIconContainer>
|
||||
<StyledDisplayMessage>{displayMessage}</StyledDisplayMessage>
|
||||
</StyledLeftContent>
|
||||
<StyledRightContent>
|
||||
<StyledToolBadge>{toolCall.toolName}</StyledToolBadge>
|
||||
<StyledIconContainer style={{ color: statusColor }}>
|
||||
<StatusIcon size={theme.icon.size.sm} />
|
||||
</StyledIconContainer>
|
||||
{isExpandable &&
|
||||
(isExpanded ? (
|
||||
<IconChevronUp size={theme.icon.size.sm} />
|
||||
) : (
|
||||
<IconChevronDown size={theme.icon.size.sm} />
|
||||
))}
|
||||
</StyledRightContent>
|
||||
</StyledToggleButton>
|
||||
|
||||
{isExpandable && (
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
|
||||
<StyledContentContainer>
|
||||
{hasError && isDefined(toolCall.errorMessage) ? (
|
||||
<StyledErrorMessage>{toolCall.errorMessage}</StyledErrorMessage>
|
||||
) : (
|
||||
<>
|
||||
<StyledTabContainer>
|
||||
<StyledTab
|
||||
type="button"
|
||||
isActive={activeTab === 'output'}
|
||||
onClick={() => setActiveTab('output')}
|
||||
>
|
||||
{t`Output`}
|
||||
</StyledTab>
|
||||
<StyledTab
|
||||
type="button"
|
||||
isActive={activeTab === 'input'}
|
||||
onClick={() => setActiveTab('input')}
|
||||
>
|
||||
{t`Input`}
|
||||
</StyledTab>
|
||||
</StyledTabContainer>
|
||||
|
||||
<StyledJsonTreeContainer>
|
||||
<JsonTree
|
||||
value={
|
||||
(activeTab === 'output'
|
||||
? (toolCall.output ?? t`No output`)
|
||||
: (toolCall.input ?? t`No input`)) as JsonValue
|
||||
}
|
||||
shouldExpandNodeInitially={() => false}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
</StyledJsonTreeContainer>
|
||||
</>
|
||||
)}
|
||||
</StyledContentContainer>
|
||||
</AnimatedExpandableContainer>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+19
@@ -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`;
|
||||
};
|
||||
+131
@@ -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};
|
||||
`;
|
||||
+2
-1
@@ -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',
|
||||
}
|
||||
|
||||
+8
-1
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
+129
@@ -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<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatObjectMetadataMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
]);
|
||||
|
||||
const workflowRunObject =
|
||||
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
|
||||
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<FlatFieldMetadata>({
|
||||
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<CreateFieldInput, 'workspaceId'> = {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+20
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+3
-2
@@ -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),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
+48
@@ -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');
|
||||
});
|
||||
});
|
||||
+6
@@ -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, '');
|
||||
+8
@@ -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
|
||||
? [
|
||||
// {
|
||||
|
||||
+2
@@ -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,
|
||||
},
|
||||
|
||||
+2
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<string, string>
|
||||
| undefined,
|
||||
result: error.response?.data,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+98
-11
@@ -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<ReturnType<typeof generateText>>);
|
||||
|
||||
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<ReturnType<typeof generateText>>);
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+59
-42
@@ -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<ToolSet>[] = [];
|
||||
|
||||
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) {
|
||||
|
||||
+5
-1
@@ -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<ToolSet>[];
|
||||
modelId?: string;
|
||||
totalCostInDollars?: number;
|
||||
creditsUsedMicro?: number;
|
||||
}
|
||||
|
||||
+226
@@ -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<ToolSet>['content'][number];
|
||||
|
||||
const buildStep = (content: StepContentPart[]): StepResult<ToolSet> =>
|
||||
({ content }) as unknown as StepResult<ToolSet>;
|
||||
|
||||
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<Record<string, unknown>> };
|
||||
};
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
+142
@@ -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<string, unknown> = {};
|
||||
|
||||
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<ToolSet>[],
|
||||
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<string, AiToolCallLog>();
|
||||
|
||||
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;
|
||||
};
|
||||
+5
-1
@@ -18,7 +18,11 @@ export type DisconnectObject = {
|
||||
};
|
||||
|
||||
export type EntityRelationFields<T> = {
|
||||
[K in keyof T]: T[K] extends BaseWorkspaceEntity | null ? K : never;
|
||||
[K in keyof T]: NonNullable<T[K]> extends
|
||||
| BaseWorkspaceEntity
|
||||
| BaseWorkspaceEntity[]
|
||||
? K
|
||||
: never;
|
||||
}[keyof T];
|
||||
|
||||
export type QueryDeepPartialEntityWithNestedRelationFields<T> = Omit<
|
||||
|
||||
+1
@@ -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: {
|
||||
|
||||
+201
-198
@@ -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": {},
|
||||
},
|
||||
|
||||
+20
@@ -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,
|
||||
|
||||
+5
-1
@@ -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<WorkflowVersionWorkspaceEntity>;
|
||||
|
||||
+4
-1
@@ -54,7 +54,10 @@ export class WorkflowVersionStepHelpersWorkspaceService {
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const updateData: Partial<WorkflowVersionWorkspaceEntity> = {};
|
||||
const updateData: Pick<
|
||||
Partial<WorkflowVersionWorkspaceEntity>,
|
||||
'steps' | 'trigger'
|
||||
> = {};
|
||||
|
||||
if (steps !== undefined) {
|
||||
updateData.steps = steps;
|
||||
|
||||
+9
-5
@@ -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:
|
||||
|
||||
+67
-15
@@ -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<AgentEntity>,
|
||||
) {}
|
||||
@@ -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<void> {
|
||||
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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+98
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+52
@@ -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,
|
||||
};
|
||||
};
|
||||
+2
-1
@@ -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],
|
||||
})
|
||||
|
||||
+41
-1
@@ -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<void> {
|
||||
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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -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<string, LogLevel> = {
|
||||
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,
|
||||
};
|
||||
};
|
||||
+129
@@ -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<string, unknown>): 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<Pick<HttpTool, 'execute'>>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
+12
@@ -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 {}
|
||||
+52
@@ -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<WorkflowHttpRequestActionInput> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
+363
@@ -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');
|
||||
});
|
||||
});
|
||||
+276
@@ -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<string, unknown> | undefined,
|
||||
): Record<string, string> => {
|
||||
if (!headers) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const redacted: Record<string, string> = {};
|
||||
|
||||
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<string, unknown> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
+139
@@ -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('<p>rendered html</p>'),
|
||||
}),
|
||||
);
|
||||
|
||||
const baseSettings: WorkflowActionSettings = {
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: false },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {},
|
||||
};
|
||||
|
||||
const buildDraftEmailStep = (input: Record<string, unknown>): 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<Pick<DraftEmailTool, 'execute'>>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
+37
-34
@@ -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<string, unknown>,
|
||||
): WorkflowAction =>
|
||||
const buildSendEmailStep = (input: Record<string, unknown>): 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<Pick<SendEmailTool, 'execute'>>;
|
||||
let mockDraftEmailTool: jest.Mocked<Pick<DraftEmailTool, 'execute'>>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+40
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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<WorkflowSendEmailActionInput> {
|
||||
protected abstract getMode(): EmailStepLogMode;
|
||||
|
||||
protected override async preprocessInput(
|
||||
rawInput: WorkflowSendEmailActionInput,
|
||||
context: Record<string, unknown>,
|
||||
): Promise<WorkflowSendEmailActionInput> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+13
@@ -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 {}
|
||||
+40
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -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: '<p>Hello</p>',
|
||||
};
|
||||
|
||||
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 = `<p>${'x'.repeat(20_000)}</p>`;
|
||||
|
||||
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: '<script>alert("xss")</script><p>Hello</p>',
|
||||
},
|
||||
output: {
|
||||
...successOutput,
|
||||
result: {
|
||||
...(successOutput.result as object),
|
||||
sanitizedHtmlBody: '<p>Hello</p>',
|
||||
plainTextBody: 'Hello',
|
||||
},
|
||||
},
|
||||
durationMs: 10,
|
||||
});
|
||||
|
||||
if (stepLog.details.type !== 'EMAIL') {
|
||||
throw new Error('Expected EMAIL details');
|
||||
}
|
||||
|
||||
expect(stepLog.details.bodyPreview).toBe('<p>Hello</p>');
|
||||
expect(stepLog.details.bodyPreview).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('falls back to the raw input body when the tool failed before composing', () => {
|
||||
const stepLog = buildEmailStepLog({
|
||||
mode: 'SEND',
|
||||
input: { ...baseInput, body: '<p>Hello</p>' },
|
||||
output: {
|
||||
success: false,
|
||||
message: 'Failed to send',
|
||||
error: 'Auth expired',
|
||||
},
|
||||
durationMs: 5,
|
||||
});
|
||||
|
||||
if (stepLog.details.type !== 'EMAIL') {
|
||||
throw new Error('Expected EMAIL details');
|
||||
}
|
||||
|
||||
expect(stepLog.details.bodyPreview).toBe('<p>Hello</p>');
|
||||
});
|
||||
|
||||
it('omits cc/bcc when neither input nor output provides them', () => {
|
||||
const stepLog = buildEmailStepLog({
|
||||
mode: 'SEND',
|
||||
input: { ...baseInput, recipients: { to: 'alice@example.com' } },
|
||||
output: successOutput,
|
||||
durationMs: 10,
|
||||
});
|
||||
|
||||
if (stepLog.details.type !== 'EMAIL') {
|
||||
throw new Error('Expected EMAIL details');
|
||||
}
|
||||
|
||||
expect(stepLog.details.recipients.cc).toBeUndefined();
|
||||
expect(stepLog.details.recipients.bcc).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { type WorkflowRunStepLog } from 'twenty-shared/workflow';
|
||||
|
||||
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 { truncateStringToUtf8ByteBudget } from 'src/utils/truncate-string-to-utf8-byte-budget.util';
|
||||
|
||||
const MAX_BODY_PREVIEW_BYTES = 8_000;
|
||||
|
||||
export type EmailStepLogMode = 'SEND' | 'DRAFT';
|
||||
|
||||
const splitRecipients = (raw: string | undefined): string[] => {
|
||||
if (raw === undefined || raw === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return raw
|
||||
.split(/[,;]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
};
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||||
|
||||
const pickRecipients = (
|
||||
inputValue: string | undefined,
|
||||
outputValue: unknown,
|
||||
): string[] => {
|
||||
if (isStringArray(outputValue)) {
|
||||
return outputValue;
|
||||
}
|
||||
|
||||
return splitRecipients(inputValue);
|
||||
};
|
||||
|
||||
const truncateBody = (body: string | undefined) => {
|
||||
if (body === undefined || body === null || body.length === 0) {
|
||||
return {
|
||||
bodyPreview: undefined,
|
||||
bodyBytes: undefined,
|
||||
bodyTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { value, originalBytes, truncated } = truncateStringToUtf8ByteBudget(
|
||||
body,
|
||||
MAX_BODY_PREVIEW_BYTES,
|
||||
);
|
||||
|
||||
return {
|
||||
bodyPreview: value,
|
||||
bodyBytes: originalBytes,
|
||||
bodyTruncated: truncated,
|
||||
};
|
||||
};
|
||||
|
||||
const extractString = (output: ToolOutput, key: string): string | undefined => {
|
||||
if (!output.result || typeof output.result !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = (output.result as Record<string, unknown>)[key];
|
||||
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
};
|
||||
|
||||
const extractNumber = (output: ToolOutput, key: string): number | undefined => {
|
||||
if (!output.result || typeof output.result !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = (output.result as Record<string, unknown>)[key];
|
||||
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
};
|
||||
|
||||
const extractRecipientsField = (output: ToolOutput, key: string): unknown => {
|
||||
if (!output.result || typeof output.result !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (output.result as Record<string, unknown>)[key];
|
||||
};
|
||||
|
||||
export const buildEmailStepLog = ({
|
||||
mode,
|
||||
input,
|
||||
output,
|
||||
durationMs,
|
||||
}: {
|
||||
mode: EmailStepLogMode;
|
||||
input: WorkflowSendEmailActionInput;
|
||||
output: ToolOutput;
|
||||
durationMs: number;
|
||||
}): WorkflowRunStepLog => {
|
||||
const to = pickRecipients(
|
||||
input.recipients?.to,
|
||||
extractRecipientsField(output, 'recipients'),
|
||||
);
|
||||
const cc = pickRecipients(
|
||||
input.recipients?.cc,
|
||||
extractRecipientsField(output, 'ccRecipients'),
|
||||
);
|
||||
const bcc = pickRecipients(
|
||||
input.recipients?.bcc,
|
||||
extractRecipientsField(output, 'bccRecipients'),
|
||||
);
|
||||
|
||||
const subject = extractString(output, 'subject') ?? input.subject;
|
||||
const connectedAccountId =
|
||||
extractString(output, 'connectedAccountId') ?? input.connectedAccountId;
|
||||
const attachmentCount = extractNumber(output, 'attachmentCount');
|
||||
|
||||
const bodyForLog =
|
||||
extractString(output, 'sanitizedHtmlBody') ??
|
||||
extractString(output, 'plainTextBody') ??
|
||||
input.body;
|
||||
const body = truncateBody(bodyForLog);
|
||||
|
||||
return {
|
||||
details: {
|
||||
type: 'EMAIL',
|
||||
mode,
|
||||
status: output.success ? 'SUCCESS' : 'ERROR',
|
||||
recipients: {
|
||||
to,
|
||||
cc: cc.length > 0 ? cc : undefined,
|
||||
bcc: bcc.length > 0 ? bcc : undefined,
|
||||
},
|
||||
subject,
|
||||
bodyPreview: body.bodyPreview,
|
||||
bodyBytes: body.bodyBytes,
|
||||
bodyTruncated: body.bodyTruncated,
|
||||
connectedAccountId,
|
||||
attachmentCount,
|
||||
inReplyTo: input.inReplyTo,
|
||||
error: output.error,
|
||||
durationMs,
|
||||
},
|
||||
entries: [],
|
||||
sizeBytes: 0,
|
||||
};
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
|
||||
import {
|
||||
isDefined,
|
||||
parseJson,
|
||||
resolveRichTextVariables,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { renderRichTextToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util';
|
||||
|
||||
export const resolveEmailBody = async (
|
||||
body: string,
|
||||
context: Record<string, unknown>,
|
||||
): Promise<string> => {
|
||||
const bodyWithResolvedVariables = resolveRichTextVariables(body, context);
|
||||
const tipTapDocument = isDefined(bodyWithResolvedVariables)
|
||||
? parseJson<JSONContent>(bodyWithResolvedVariables)
|
||||
: null;
|
||||
|
||||
if (isDefined(tipTapDocument) && tipTapDocument.type === 'doc') {
|
||||
return renderRichTextToHtml(tipTapDocument);
|
||||
}
|
||||
|
||||
return bodyWithResolvedVariables ?? body;
|
||||
};
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { resolveInput } from 'twenty-shared/utils';
|
||||
import { type WorkflowRunStepLog } from 'twenty-shared/workflow';
|
||||
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
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 { type WorkflowAction as WorkflowActionContract } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
|
||||
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 { 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';
|
||||
|
||||
type BuildStepLogArgs<TInput> = {
|
||||
input: TInput;
|
||||
output: ToolOutput;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export abstract class ToolBackedWorkflowAction<
|
||||
TInput extends ToolInput,
|
||||
> implements WorkflowActionContract {
|
||||
protected readonly logger: Logger;
|
||||
|
||||
protected constructor(
|
||||
loggerName: string,
|
||||
private readonly workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
|
||||
) {
|
||||
this.logger = new Logger(loggerName);
|
||||
}
|
||||
|
||||
protected abstract getTool(): Tool;
|
||||
|
||||
protected abstract assertStep(step: WorkflowAction): void;
|
||||
|
||||
protected async preprocessInput(
|
||||
rawInput: TInput,
|
||||
_context: Record<string, unknown>,
|
||||
): Promise<TInput> {
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
protected abstract buildStepLog(
|
||||
args: BuildStepLogArgs<TInput>,
|
||||
): WorkflowRunStepLog;
|
||||
|
||||
async execute({
|
||||
currentStepId,
|
||||
steps,
|
||||
context,
|
||||
runInfo,
|
||||
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
|
||||
const step = findStepOrThrow({ stepId: currentStepId, steps });
|
||||
|
||||
this.assertStep(step);
|
||||
|
||||
const rawInput = step.settings.input as TInput;
|
||||
const preprocessed = await this.preprocessInput(rawInput, context);
|
||||
const resolvedInput = resolveInput(preprocessed, context) as TInput;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const toolOutput = await this.getTool().execute(resolvedInput, {
|
||||
workspaceId: runInfo.workspaceId,
|
||||
});
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
||||
await this.persistStepLog({
|
||||
workflowRunId: runInfo.workflowRunId,
|
||||
workspaceId: runInfo.workspaceId,
|
||||
stepId: currentStepId,
|
||||
input: resolvedInput,
|
||||
output: toolOutput,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
return {
|
||||
result: toolOutput.result as object,
|
||||
error: toolOutput.error,
|
||||
};
|
||||
}
|
||||
|
||||
private async persistStepLog({
|
||||
workflowRunId,
|
||||
workspaceId,
|
||||
stepId,
|
||||
input,
|
||||
output,
|
||||
durationMs,
|
||||
}: {
|
||||
workflowRunId: string;
|
||||
workspaceId: string;
|
||||
stepId: string;
|
||||
input: TInput;
|
||||
output: ToolOutput;
|
||||
durationMs: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await this.workflowRunStepLogService.setStepLog({
|
||||
workflowRunId,
|
||||
workspaceId,
|
||||
stepId,
|
||||
stepLog: this.buildStepLog({ input, output, durationMs }),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to persist step log for workflowRun=${workflowRunId} step=${stepId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
|
||||
import {
|
||||
isDefined,
|
||||
parseJson,
|
||||
resolveInput,
|
||||
resolveRichTextVariables,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
|
||||
|
||||
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
|
||||
import { renderRichTextToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util';
|
||||
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.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 { 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 { type WorkflowSendEmailActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-input.type';
|
||||
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
@Injectable()
|
||||
export class ToolExecutorWorkflowAction implements WorkflowAction {
|
||||
private readonly toolsByActionType: Map<WorkflowActionType, Tool>;
|
||||
|
||||
constructor(
|
||||
private readonly httpTool: HttpTool,
|
||||
private readonly sendEmailTool: SendEmailTool,
|
||||
private readonly draftEmailTool: DraftEmailTool,
|
||||
) {
|
||||
this.toolsByActionType = new Map<WorkflowActionType, Tool>([
|
||||
[WorkflowActionType.HTTP_REQUEST, this.httpTool],
|
||||
[WorkflowActionType.SEND_EMAIL, this.sendEmailTool],
|
||||
[WorkflowActionType.DRAFT_EMAIL, this.draftEmailTool],
|
||||
]);
|
||||
}
|
||||
|
||||
private async resolveEmailBody(
|
||||
body: string,
|
||||
context: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const bodyWithResolvedVariables = resolveRichTextVariables(body, context);
|
||||
const tipTapDocument = isDefined(bodyWithResolvedVariables)
|
||||
? parseJson<JSONContent>(bodyWithResolvedVariables)
|
||||
: null;
|
||||
|
||||
if (isDefined(tipTapDocument) && tipTapDocument.type === 'doc') {
|
||||
return renderRichTextToHtml(tipTapDocument);
|
||||
}
|
||||
|
||||
return bodyWithResolvedVariables ?? body;
|
||||
}
|
||||
|
||||
async execute({
|
||||
currentStepId,
|
||||
steps,
|
||||
context,
|
||||
runInfo,
|
||||
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
|
||||
const step = steps.find((step) => step.id === currentStepId);
|
||||
|
||||
if (!step) {
|
||||
throw new WorkflowStepExecutorException(
|
||||
'Step not found',
|
||||
WorkflowStepExecutorExceptionCode.STEP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const tool = this.toolsByActionType.get(step.type);
|
||||
|
||||
if (!tool) {
|
||||
throw new WorkflowStepExecutorException(
|
||||
`No tool found for workflow action type: ${step.type}`,
|
||||
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
let toolInput = step.settings.input;
|
||||
|
||||
if (
|
||||
step.type === WorkflowActionType.SEND_EMAIL ||
|
||||
step.type === WorkflowActionType.DRAFT_EMAIL
|
||||
) {
|
||||
const emailInput = toolInput as WorkflowSendEmailActionInput;
|
||||
|
||||
if (isDefined(emailInput.body)) {
|
||||
const emailBody = await this.resolveEmailBody(emailInput.body, context);
|
||||
toolInput = {
|
||||
...emailInput,
|
||||
body: emailBody,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
toolInput = resolveInput(toolInput, context) as ToolInput;
|
||||
|
||||
const toolOutput = await tool.execute(toolInput, {
|
||||
workspaceId: runInfo.workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
result: toolOutput.result as object,
|
||||
error: toolOutput.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
+5
-8
@@ -4,7 +4,6 @@ import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
|
||||
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
|
||||
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory';
|
||||
import { AiAgentActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module';
|
||||
@@ -13,11 +12,12 @@ import { DelayActionModule } from 'src/modules/workflow/workflow-executor/workfl
|
||||
import { EmptyActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/empty/empty-action.module';
|
||||
import { FilterActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module';
|
||||
import { FormActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form-action.module';
|
||||
import { HttpRequestActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request-action.module';
|
||||
import { IfElseActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/if-else-action.module';
|
||||
import { IteratorActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator-action.module';
|
||||
import { LogicFunctionActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function-action.module';
|
||||
import { MailSenderActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/mail-sender-action.module';
|
||||
import { RecordCRUDActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module';
|
||||
import { ToolExecutorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action';
|
||||
import { WorkflowExecutorWorkspaceService } from 'src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service';
|
||||
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
|
||||
|
||||
@@ -38,14 +38,11 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
|
||||
AiAgentActionModule,
|
||||
EmptyActionModule,
|
||||
FeatureFlagModule,
|
||||
ToolModule,
|
||||
HttpRequestActionModule,
|
||||
MailSenderActionModule,
|
||||
MetricsModule,
|
||||
],
|
||||
providers: [
|
||||
WorkflowExecutorWorkspaceService,
|
||||
WorkflowActionFactory,
|
||||
ToolExecutorWorkflowAction,
|
||||
],
|
||||
providers: [WorkflowExecutorWorkspaceService, WorkflowActionFactory],
|
||||
exports: [WorkflowExecutorWorkspaceService],
|
||||
})
|
||||
export class WorkflowExecutorModule {}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { type WorkflowRunStepLog } from 'twenty-shared/workflow';
|
||||
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { type WorkflowRunWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
|
||||
|
||||
const MAX_STEP_LOG_BYTES = 256_000;
|
||||
|
||||
const computeSizeBytes = (value: unknown): number => {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8');
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowRunStepLogWorkspaceService {
|
||||
private readonly logger = new Logger(WorkflowRunStepLogWorkspaceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
async setStepLog({
|
||||
workflowRunId,
|
||||
workspaceId,
|
||||
stepId,
|
||||
stepLog,
|
||||
}: {
|
||||
workflowRunId: string;
|
||||
workspaceId: string;
|
||||
stepId: string;
|
||||
stepLog: WorkflowRunStepLog;
|
||||
}): Promise<void> {
|
||||
const isStepLogsEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_WORKFLOW_RUN_STEP_LOGS_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isStepLogsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeBytes = computeSizeBytes(stepLog);
|
||||
|
||||
if (sizeBytes > MAX_STEP_LOG_BYTES) {
|
||||
this.logger.warn(
|
||||
`Step log for workflowRun=${workflowRunId} step=${stepId} exceeds cap (${sizeBytes}b > ${MAX_STEP_LOG_BYTES}b) and will be dropped`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const stepLogWithSize: WorkflowRunStepLog = {
|
||||
...stepLog,
|
||||
sizeBytes,
|
||||
};
|
||||
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
const workflowRunRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkflowRunWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workflowRun',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
await workflowRunRepository
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({
|
||||
stepLogs: () =>
|
||||
`jsonb_set(COALESCE("stepLogs", '{}'::jsonb), ARRAY[:stepId]::text[], :stepLog::jsonb, true)`,
|
||||
})
|
||||
.where('id = :workflowRunId', { workflowRunId })
|
||||
.setParameters({
|
||||
stepId,
|
||||
stepLog: JSON.stringify(stepLogWithSize),
|
||||
})
|
||||
.execute();
|
||||
}, authContext);
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -4,12 +4,14 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
|
||||
import { DeleteWorkflowRunsCommand } from 'src/modules/workflow/workflow-runner/workflow-run/command/delete-workflow-runs.command';
|
||||
import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service';
|
||||
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
|
||||
|
||||
@Module({
|
||||
@@ -23,8 +25,17 @@ import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runne
|
||||
CacheLockModule,
|
||||
MetricsModule,
|
||||
WorkspaceIteratorModule,
|
||||
FeatureFlagModule,
|
||||
],
|
||||
providers: [
|
||||
WorkflowRunWorkspaceService,
|
||||
WorkflowRunStepLogWorkspaceService,
|
||||
DeleteWorkflowRunsCommand,
|
||||
],
|
||||
exports: [
|
||||
WorkflowRunWorkspaceService,
|
||||
WorkflowRunStepLogWorkspaceService,
|
||||
DeleteWorkflowRunsCommand,
|
||||
],
|
||||
providers: [WorkflowRunWorkspaceService, DeleteWorkflowRunsCommand],
|
||||
exports: [WorkflowRunWorkspaceService, DeleteWorkflowRunsCommand],
|
||||
})
|
||||
export class WorkflowRunModule {}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
TRUNCATION_SENTINEL,
|
||||
truncateStringToUtf8ByteBudget,
|
||||
utf8ByteLengthOf,
|
||||
} from 'src/utils/truncate-string-to-utf8-byte-budget.util';
|
||||
|
||||
describe('utf8ByteLengthOf', () => {
|
||||
it('returns the UTF-8 byte length, not the UTF-16 code unit count', () => {
|
||||
expect(utf8ByteLengthOf('a')).toBe(1);
|
||||
expect(utf8ByteLengthOf('é')).toBe(2);
|
||||
expect(utf8ByteLengthOf('日')).toBe(3);
|
||||
expect(utf8ByteLengthOf('😀')).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateStringToUtf8ByteBudget', () => {
|
||||
it('returns the input unchanged when under the byte budget', () => {
|
||||
const result = truncateStringToUtf8ByteBudget('hello', 100);
|
||||
|
||||
expect(result).toEqual({
|
||||
value: 'hello',
|
||||
originalBytes: 5,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the input unchanged exactly at the byte budget', () => {
|
||||
const result = truncateStringToUtf8ByteBudget('hello', 5);
|
||||
|
||||
expect(result.truncated).toBe(false);
|
||||
expect(result.value).toBe('hello');
|
||||
});
|
||||
|
||||
it('truncates ASCII content and appends the sentinel', () => {
|
||||
const result = truncateStringToUtf8ByteBudget('x'.repeat(100), 10);
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.originalBytes).toBe(100);
|
||||
expect(result.value).toBe(`${'x'.repeat(10)}${TRUNCATION_SENTINEL}`);
|
||||
});
|
||||
|
||||
it('reports originalBytes in UTF-8 bytes for non-ASCII content', () => {
|
||||
// 1 × '日' = 3 UTF-8 bytes but only 1 UTF-16 code unit.
|
||||
const result = truncateStringToUtf8ByteBudget('日'.repeat(1_000), 30);
|
||||
|
||||
expect(result.originalBytes).toBe(3_000);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('truncates CJK content within budget rather than 3× over (regression)', () => {
|
||||
// Pre-fix `slice(0, maxBytes)` would have emitted 32_000 chars =
|
||||
// ~96_000 bytes. The byte-aware util must stay close to the cap.
|
||||
const cjk = '日'.repeat(40_000);
|
||||
const cap = 32_000;
|
||||
|
||||
const result = truncateStringToUtf8ByteBudget(cjk, cap);
|
||||
|
||||
const truncatedPayloadBytes = utf8ByteLengthOf(
|
||||
result.value.replace(TRUNCATION_SENTINEL, ''),
|
||||
);
|
||||
|
||||
// Allow at most a single U+FFFD substitution (3 UTF-8 bytes) when the
|
||||
// byte boundary falls inside a multi-byte sequence.
|
||||
expect(truncatedPayloadBytes).toBeLessThanOrEqual(cap + 3);
|
||||
expect(truncatedPayloadBytes).toBeGreaterThan(cap - 3);
|
||||
});
|
||||
|
||||
it('handles emoji (surrogate pair, 4 UTF-8 bytes) without exceeding the budget', () => {
|
||||
const emoji = '😀'.repeat(10_000);
|
||||
const cap = 4_000;
|
||||
|
||||
const result = truncateStringToUtf8ByteBudget(emoji, cap);
|
||||
|
||||
const truncatedPayloadBytes = utf8ByteLengthOf(
|
||||
result.value.replace(TRUNCATION_SENTINEL, ''),
|
||||
);
|
||||
|
||||
expect(result.originalBytes).toBe(40_000);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(truncatedPayloadBytes).toBeLessThanOrEqual(cap + 3);
|
||||
});
|
||||
|
||||
it('returns an empty value when the budget is zero', () => {
|
||||
const result = truncateStringToUtf8ByteBudget('hello', 0);
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.value).toBe(TRUNCATION_SENTINEL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export const TRUNCATION_SENTINEL = '…[truncated]';
|
||||
|
||||
export type Utf8TruncationResult = {
|
||||
value: string;
|
||||
originalBytes: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export const utf8ByteLengthOf = (value: string): number =>
|
||||
Buffer.byteLength(value, 'utf8');
|
||||
|
||||
export const truncateStringToUtf8ByteBudget = (
|
||||
value: string,
|
||||
maxBytes: number,
|
||||
): Utf8TruncationResult => {
|
||||
const originalBytes = utf8ByteLengthOf(value);
|
||||
|
||||
if (originalBytes <= maxBytes) {
|
||||
return { value, originalBytes, truncated: false };
|
||||
}
|
||||
|
||||
const truncated = Buffer.from(value, 'utf8')
|
||||
.subarray(0, maxBytes)
|
||||
.toString('utf8');
|
||||
|
||||
return {
|
||||
value: `${truncated}${TRUNCATION_SENTINEL}`,
|
||||
originalBytes,
|
||||
truncated: true,
|
||||
};
|
||||
};
|
||||
@@ -2392,6 +2392,9 @@ export const STANDARD_OBJECTS = {
|
||||
universalIdentifier: '730dc1c9-34f5-4c22-84a6-bcb55b7604e2',
|
||||
},
|
||||
state: { universalIdentifier: '20202020-611f-45f3-9cde-d64927e8ec57' },
|
||||
stepLogs: {
|
||||
universalIdentifier: '20202020-7c4e-4e1a-8fc1-1e3a55d6c2a1',
|
||||
},
|
||||
timelineActivities: {
|
||||
universalIdentifier: '20202020-af4d-4eb0-babc-eb960a45b356',
|
||||
},
|
||||
|
||||
@@ -8,4 +8,5 @@ export enum FeatureFlagKey {
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED',
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
|
||||
IS_WORKFLOW_RUN_STEP_LOGS_ENABLED = 'IS_WORKFLOW_RUN_STEP_LOGS_ENABLED',
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ export { workflowRunStateSchema } from './schemas/workflow-run-state-schema';
|
||||
export { workflowRunStateStepInfoSchema } from './schemas/workflow-run-state-step-info-schema';
|
||||
export { workflowRunStateStepInfosSchema } from './schemas/workflow-run-state-step-infos-schema';
|
||||
export { workflowRunStatusSchema } from './schemas/workflow-run-status-schema';
|
||||
export {
|
||||
workflowRunStepLogSchema,
|
||||
workflowRunStepLogsSchema,
|
||||
} from './schemas/workflow-run-step-log-schema';
|
||||
export { workflowRunStepStatusSchema } from './schemas/workflow-run-step-status-schema';
|
||||
export { workflowTriggerSchema } from './schemas/workflow-trigger-schema';
|
||||
export type { EmailRecipients } from './types/EmailRecipients';
|
||||
@@ -82,6 +86,12 @@ export type {
|
||||
WorkflowRunStepInfos,
|
||||
} from './types/WorkflowRunStateStepInfos';
|
||||
export { StepStatus } from './types/WorkflowRunStateStepInfos';
|
||||
export type {
|
||||
WorkflowRunStepLog,
|
||||
WorkflowRunStepLogs,
|
||||
AiAgentStepLogDetails,
|
||||
AiToolCallLog,
|
||||
} from './types/WorkflowRunStepLog';
|
||||
export { canObjectBeManagedByAutomation } from './utils/canObjectBeManagedByAutomation';
|
||||
export { extractRawVariableNamePart } from './utils/extractRawVariableNameParts';
|
||||
export { getFunctionInputFromInputSchema } from './utils/getFunctionInputFromInputSchema';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { workflowRunStateSchema } from './workflow-run-state-schema';
|
||||
import { workflowRunStatusSchema } from './workflow-run-status-schema';
|
||||
import { workflowRunStepLogsSchema } from './workflow-run-step-log-schema';
|
||||
|
||||
export const workflowRunSchema = z.looseObject({
|
||||
__typename: z.literal('WorkflowRun'),
|
||||
@@ -8,6 +9,7 @@ export const workflowRunSchema = z.looseObject({
|
||||
workflowVersionId: z.string(),
|
||||
workflowId: z.string(),
|
||||
state: workflowRunStateSchema.nullable(),
|
||||
stepLogs: workflowRunStepLogsSchema.nullable().optional(),
|
||||
status: workflowRunStatusSchema,
|
||||
createdAt: z.string(),
|
||||
deletedAt: z.string().nullable(),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const stepLogEntrySchema = z.object({
|
||||
timestamp: z.string(),
|
||||
level: z.enum(['debug', 'info', 'warn', 'error']),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const aiToolCallLogSchema = z.object({
|
||||
toolName: z.string(),
|
||||
toolCallId: z.string(),
|
||||
providerExecuted: z.boolean().optional(),
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
state: z.enum(['started', 'success', 'error', 'awaiting-approval']),
|
||||
});
|
||||
|
||||
const aiAgentStepLogDetailsSchema = z.object({
|
||||
type: z.literal('AI_AGENT'),
|
||||
modelId: z.string(),
|
||||
usage: z.object({
|
||||
inputTokens: z.number(),
|
||||
outputTokens: z.number(),
|
||||
reasoningTokens: z.number().optional(),
|
||||
cacheReadTokens: z.number().optional(),
|
||||
cacheCreationTokens: z.number().optional(),
|
||||
totalTokens: z.number(),
|
||||
}),
|
||||
cost: z.object({
|
||||
totalCostInDollars: z.number(),
|
||||
creditsUsedMicro: z.number(),
|
||||
}),
|
||||
nativeWebSearchCallCount: z.number(),
|
||||
toolCalls: z.array(aiToolCallLogSchema),
|
||||
durationMs: z.number(),
|
||||
});
|
||||
|
||||
const codeStepLogDetailsSchema = z.object({
|
||||
type: z.literal('CODE'),
|
||||
durationMs: z.number(),
|
||||
status: z.enum(['SUCCESS', 'ERROR']),
|
||||
error: z
|
||||
.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
stackTrace: z.string().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const httpRequestStepLogDetailsSchema = z.object({
|
||||
type: z.literal('HTTP_REQUEST'),
|
||||
request: z.object({
|
||||
method: z.string(),
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
body: z.string().optional(),
|
||||
bodyBytes: z.number().optional(),
|
||||
bodyTruncated: z.boolean().optional(),
|
||||
}),
|
||||
// `response` is absent for transport-level failures (DNS, timeout, TLS,
|
||||
// etc.) — only `error` is set in that case.
|
||||
response: z
|
||||
.object({
|
||||
status: z.number(),
|
||||
statusText: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
body: z.string().optional(),
|
||||
bodyBytes: z.number().optional(),
|
||||
bodyTruncated: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
durationMs: z.number(),
|
||||
});
|
||||
|
||||
const emailStepLogDetailsSchema = z.object({
|
||||
type: z.literal('EMAIL'),
|
||||
mode: z.enum(['SEND', 'DRAFT']),
|
||||
status: z.enum(['SUCCESS', 'ERROR']),
|
||||
recipients: z.object({
|
||||
to: z.array(z.string()),
|
||||
cc: z.array(z.string()).optional(),
|
||||
bcc: z.array(z.string()).optional(),
|
||||
}),
|
||||
subject: z.string().optional(),
|
||||
bodyPreview: z.string().optional(),
|
||||
bodyBytes: z.number().optional(),
|
||||
bodyTruncated: z.boolean().optional(),
|
||||
connectedAccountId: z.string().optional(),
|
||||
attachmentCount: z.number().optional(),
|
||||
inReplyTo: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
durationMs: z.number(),
|
||||
});
|
||||
|
||||
const stepLogDetailsSchema = z.discriminatedUnion('type', [
|
||||
aiAgentStepLogDetailsSchema,
|
||||
codeStepLogDetailsSchema,
|
||||
httpRequestStepLogDetailsSchema,
|
||||
emailStepLogDetailsSchema,
|
||||
]);
|
||||
|
||||
export const workflowRunStepLogSchema = z.object({
|
||||
details: stepLogDetailsSchema,
|
||||
entries: z.array(stepLogEntrySchema),
|
||||
truncated: z
|
||||
.object({
|
||||
droppedEntries: z.number(),
|
||||
droppedBytes: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
sizeBytes: z.number(),
|
||||
});
|
||||
|
||||
// We intentionally keep the runtime schema permissive: the column is a
|
||||
// JSONB blob written by the server and the consumers don't validate
|
||||
// individual `details` shapes. The strict per-step type (with the
|
||||
// discriminated `details` union) lives in `WorkflowRunStepLog` and is
|
||||
// applied at the boundaries that *produce* logs (server-side writers).
|
||||
// Tighter zod parsing here would collapse the discriminated union to `{}`
|
||||
// when inferred through `z.record`, breaking front-end indexing.
|
||||
export const workflowRunStepLogsSchema = z.record(z.string(), z.unknown());
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type workflowRunStepLogSchema } from '@/workflow/schemas/workflow-run-step-log-schema';
|
||||
import type z from 'zod';
|
||||
|
||||
export type WorkflowRunStepLog = z.infer<typeof workflowRunStepLogSchema>;
|
||||
|
||||
export type WorkflowRunStepLogs = Record<string, WorkflowRunStepLog>;
|
||||
|
||||
export type AiAgentStepLogDetails = Extract<
|
||||
WorkflowRunStepLog['details'],
|
||||
{ type: 'AI_AGENT' }
|
||||
>;
|
||||
|
||||
export type AiToolCallLog = AiAgentStepLogDetails['toolCalls'][number];
|
||||
Reference in New Issue
Block a user