refactor(front): move workflow run step logs into workflow-actions (#23841)

Follow-up on unapplied review feedback from #21142.

### Folder structure

@thomtrp:
> We now have a folder workflow-run/observability, but we have
workflow-run related components in workflow-actions folder already. I
would avoid that workflow-run/observability folder.

The `workflow-run/` folder held nothing but `observability/`, so it is
removed entirely and each step log detail component now sits with its
action:

| File | New location |
| --- | --- |
| `WorkflowRunStepLogsAiAgentDetail.tsx`,
`WorkflowRunStepLogsToolCallRow.tsx` |
`workflow-actions/ai-agent-action/components/` |
| `WorkflowRunStepLogsCodeDetail.tsx` |
`workflow-actions/code-action/components/` |
| `WorkflowRunStepLogsHttpRequestDetail.tsx` |
`workflow-actions/http-request-action/components/` |
| `WorkflowRunStepLogsDetail.tsx`, `WorkflowRunStepLogsEntries.tsx`,
`WorkflowRunStepLogsEmailDetail.tsx`, `workflowRunStepLogsStyles.ts` |
`workflow-actions/components/` |
| `formatDuration.ts`, `formatBytes.ts` | `workflow-actions/utils/` |

The email detail stays in the shared `components/` folder since there is
no dedicated send-email action folder on the front end (email editing
lives at the root as `WorkflowEditActionEmailBase.tsx`).
`workflowRunStepLogsStyles.ts` goes next to the shared components rather
than `utils/`, since it is styled components and not utils.

Also folds in @FelixMalfait's `2 export in 1 file` comment:
`workflowRunStepLogsFormatters.ts` is split into `formatDuration.ts` and
`formatBytes.ts`.

### AI comments

@thomtrp:
> same, let's not keep AI comments

Removes every comment #21142 introduced, across the front end, server
and shared packages:

- `WorkflowRunStepLogsEntries.tsx` - the `onlyLatestIteration` prop
block
- `workflow-run-step-log-schema.ts` - the transport-failure note and the
permissive-schema rationale
- `strip-ansi-escapes.util.spec.ts`,
`build-http-request-step-log.util.spec.ts`,
`truncate-string-to-utf8-byte-budget.spec.ts`,
`agent-async-executor.service.spec.ts` - the byte-vs-char and
pre-fix-behaviour commentary

No behaviour change. Locale catalogs are deliberately left untouched;
the next i18n run picks up the new source paths.

### Still open from #21142, not covered here

- `persistStepLog` try/catch duplicated across the code, tool-backed and
ai-agent actions
- `draft-email-tool` returning both `sanitizedHtmlBody` and
`plainTextBody`
- storing both `totalCostInDollars` and `creditsUsedMicro`
- the byte-budget truncation utilities being over-engineered
- `strip-ansi-escapes` being local to application logs
This commit is contained in:
Thomas Trompette
2026-08-06 11:48:36 +02:00
committed by GitHub
parent f3bc2325cb
commit 5278a47b55
16 changed files with 25 additions and 65 deletions
@@ -13,7 +13,7 @@ import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow'; import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow'; import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { WorkflowRunStepLogsDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsDetail'; import { WorkflowRunStepLogsDetail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowRunStepLogsDetail';
import { WorkflowRunStepInputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepInputDetail'; import { WorkflowRunStepInputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepInputDetail';
import { WorkflowRunStepNodeDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepNodeDetail'; import { WorkflowRunStepNodeDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepNodeDetail';
import { WorkflowRunStepOutputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepOutputDetail'; import { WorkflowRunStepOutputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepOutputDetail';
@@ -13,7 +13,7 @@ import {
} from 'twenty-ui/icon'; } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants'; import { themeCssVariables } from 'twenty-ui/theme-constants';
import { formatDuration } from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters'; import { formatDuration } from '@/workflow/workflow-steps/workflow-actions/utils/formatDuration';
import { import {
StyledBadgeGroup, StyledBadgeGroup,
StyledEmptyHint, StyledEmptyHint,
@@ -27,8 +27,8 @@ import {
StyledSummaryCard, StyledSummaryCard,
StyledSummaryHeader, StyledSummaryHeader,
StyledTitle, StyledTitle,
} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles'; } from '@/workflow/workflow-steps/workflow-actions/components/workflowRunStepLogsStyles';
import { WorkflowRunStepLogsToolCallRow } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsToolCallRow'; import { WorkflowRunStepLogsToolCallRow } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowRunStepLogsToolCallRow';
import { formatNumber } from '~/utils/format/formatNumber'; import { formatNumber } from '~/utils/format/formatNumber';
const StyledModelBadge = styled.span` const StyledModelBadge = styled.span`
@@ -11,7 +11,7 @@ import {
import { themeCssVariables } from 'twenty-ui/theme-constants'; import { themeCssVariables } from 'twenty-ui/theme-constants';
import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily'; import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily';
import { formatDuration } from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters'; import { formatDuration } from '@/workflow/workflow-steps/workflow-actions/utils/formatDuration';
import { import {
StyledErrorCard, StyledErrorCard,
StyledErrorMessageText, StyledErrorMessageText,
@@ -26,7 +26,7 @@ import {
StyledSummaryCard, StyledSummaryCard,
StyledSummaryHeader, StyledSummaryHeader,
StyledTitle, StyledTitle,
} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles'; } from '@/workflow/workflow-steps/workflow-actions/components/workflowRunStepLogsStyles';
const StyledErrorHeader = styled.div` const StyledErrorHeader = styled.div`
align-items: center; align-items: center;
@@ -10,11 +10,11 @@ import { type JsonValue } from 'type-fest';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow'; import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow'; import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { useWorkflowRunStepLog } from '@/workflow/hooks/useWorkflowRunStepLog'; import { useWorkflowRunStepLog } from '@/workflow/hooks/useWorkflowRunStepLog';
import { WorkflowRunStepLogsAiAgentDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsAiAgentDetail'; import { WorkflowRunStepLogsAiAgentDetail } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowRunStepLogsAiAgentDetail';
import { WorkflowRunStepLogsCodeDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsCodeDetail'; import { WorkflowRunStepLogsCodeDetail } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowRunStepLogsCodeDetail';
import { WorkflowRunStepLogsEmailDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsEmailDetail'; import { WorkflowRunStepLogsEmailDetail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowRunStepLogsEmailDetail';
import { WorkflowRunStepLogsEntries } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsEntries'; import { WorkflowRunStepLogsEntries } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowRunStepLogsEntries';
import { WorkflowRunStepLogsHttpRequestDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsHttpRequestDetail'; import { WorkflowRunStepLogsHttpRequestDetail } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowRunStepLogsHttpRequestDetail';
import { getIsDescendantOfIterator } from '@/workflow/workflow-steps/utils/getIsDescendantOfIterator'; import { getIsDescendantOfIterator } from '@/workflow/workflow-steps/utils/getIsDescendantOfIterator';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
@@ -11,10 +11,8 @@ import {
} from 'twenty-ui/icon'; } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants'; import { themeCssVariables } from 'twenty-ui/theme-constants';
import { import { formatBytes } from '@/workflow/workflow-steps/workflow-actions/utils/formatBytes';
formatBytes, import { formatDuration } from '@/workflow/workflow-steps/workflow-actions/utils/formatDuration';
formatDuration,
} from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters';
import { import {
StyledBadgeGroup, StyledBadgeGroup,
StyledBodyMeta, StyledBodyMeta,
@@ -32,7 +30,7 @@ import {
StyledSummaryCard, StyledSummaryCard,
StyledSummaryHeader, StyledSummaryHeader,
StyledTitle, StyledTitle,
} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles'; } from '@/workflow/workflow-steps/workflow-actions/components/workflowRunStepLogsStyles';
const StyledModeBadge = styled.span` const StyledModeBadge = styled.span`
background: ${themeCssVariables.background.transparent.light}; background: ${themeCssVariables.background.transparent.light};
@@ -7,7 +7,7 @@ import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily'
import { import {
StyledSection, StyledSection,
StyledSectionTitle, StyledSectionTitle,
} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles'; } from '@/workflow/workflow-steps/workflow-actions/components/workflowRunStepLogsStyles';
const StyledEntriesList = styled.div` const StyledEntriesList = styled.div`
background: ${themeCssVariables.background.tertiary}; background: ${themeCssVariables.background.tertiary};
@@ -77,9 +77,6 @@ export const WorkflowRunStepLogsEntries = ({
onlyLatestIteration = false, onlyLatestIteration = false,
}: { }: {
entries: WorkflowRunStepLog['entries']; 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; onlyLatestIteration?: boolean;
}) => { }) => {
const { t } = useLingui(); const { t } = useLingui();
@@ -13,10 +13,8 @@ import {
import { themeCssVariables } from 'twenty-ui/theme-constants'; import { themeCssVariables } from 'twenty-ui/theme-constants';
import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily'; import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily';
import { import { formatBytes } from '@/workflow/workflow-steps/workflow-actions/utils/formatBytes';
formatBytes, import { formatDuration } from '@/workflow/workflow-steps/workflow-actions/utils/formatDuration';
formatDuration,
} from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters';
import { import {
StyledBadgeGroup, StyledBadgeGroup,
StyledBodyMeta, StyledBodyMeta,
@@ -34,7 +32,7 @@ import {
StyledSummaryCard, StyledSummaryCard,
StyledSummaryHeader, StyledSummaryHeader,
StyledTitle, StyledTitle,
} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles'; } from '@/workflow/workflow-steps/workflow-actions/components/workflowRunStepLogsStyles';
const StyledMethodBadge = styled.span<{ method: string }>` const StyledMethodBadge = styled.span<{ method: string }>`
background: ${themeCssVariables.background.transparent.light}; background: ${themeCssVariables.background.transparent.light};
@@ -1,11 +1,3 @@
export const formatDuration = (ms: number): string => {
if (ms < 1000) {
return `${ms}ms`;
}
return `${(ms / 1000).toFixed(1)}s`;
};
export const formatBytes = (bytes: number): string => { export const formatBytes = (bytes: number): string => {
if (bytes < 1024) { if (bytes < 1024) {
return `${bytes} B`; return `${bytes} B`;
@@ -0,0 +1,7 @@
export const formatDuration = (ms: number): string => {
if (ms < 1000) {
return `${ms}ms`;
}
return `${(ms / 1000).toFixed(1)}s`;
};
@@ -6,7 +6,6 @@ describe('stripAnsiEscapes', () => {
}); });
it('strips SGR color codes from a yellow value', () => { it('strips SGR color codes from a yellow value', () => {
// What `console.log(chalk.yellow('4 '))` emits.
expect(stripAnsiEscapes('\u001B[33m4 \u001B[39m')).toBe('4 '); expect(stripAnsiEscapes('\u001B[33m4 \u001B[39m')).toBe('4 ');
}); });
@@ -40,9 +39,6 @@ describe('stripAnsiEscapes', () => {
}); });
it('leaves untouched the bracket text that survived a missing ESC', () => { 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'); expect(stripAnsiEscapes('[33m4 [39m')).toBe('[33m4 [39m');
}); });
}); });
@@ -348,7 +348,6 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
expect(result.nativeWebSearchCallCount).toBe(0); expect(result.nativeWebSearchCallCount).toBe(0);
expect(result.totalCostInDollars).toBeCloseTo(0.0042, 6); expect(result.totalCostInDollars).toBeCloseTo(0.0042, 6);
// credits = dollars * 1_000_000
expect(result.creditsUsedMicro).toBe(4200); expect(result.creditsUsedMicro).toBe(4200);
}); });
@@ -369,8 +368,6 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
], ],
usage: { usage: {
...baseUsage, ...baseUsage,
// inputTokens (100) is the full prompt: noCache(60) + cacheRead(10) +
// cacheCreation(30) — the emitted total must not add the 30 again
inputTokenDetails: { inputTokenDetails: {
noCacheTokens: 60, noCacheTokens: 60,
cacheReadTokens: 10, cacheReadTokens: 10,
@@ -85,12 +85,6 @@ describe('buildHttpRequestStepLog', () => {
}); });
it('truncates non-ASCII bodies by UTF-8 bytes, not UTF-16 code units', () => { 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 longCjkPayload = '日'.repeat(40_000);
const stepLog = buildHttpRequestStepLog({ const stepLog = buildHttpRequestStepLog({
@@ -197,9 +191,6 @@ describe('buildHttpRequestStepLog', () => {
}); });
it('redacts sensitive query params even when the URL is unparseable (regression)', () => { 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 = const unparseableUrl =
'https://api example.com/data?page=1&api_key=AKIA-leaked&token=oauth-leaked&safe=ok'; 'https://api example.com/data?page=1&api_key=AKIA-leaked&token=oauth-leaked&safe=ok';
@@ -226,8 +217,6 @@ describe('buildHttpRequestStepLog', () => {
}); });
it('redacts percent-encoded sensitive param names in unparseable URLs', () => { 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'; const unparseableUrl = 'https://api example.com/x?api%5Fkey=leaked';
expect(() => new URL(unparseableUrl)).toThrow(); expect(() => new URL(unparseableUrl)).toThrow();
@@ -40,7 +40,6 @@ describe('truncateStringToUtf8ByteBudget', () => {
}); });
it('reports originalBytes in UTF-8 bytes for non-ASCII content', () => { 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); const result = truncateStringToUtf8ByteBudget('日'.repeat(1_000), 30);
expect(result.originalBytes).toBe(3_000); expect(result.originalBytes).toBe(3_000);
@@ -48,8 +47,6 @@ describe('truncateStringToUtf8ByteBudget', () => {
}); });
it('truncates CJK content within budget rather than 3× over (regression)', () => { 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 cjk = '日'.repeat(40_000);
const cap = 32_000; const cap = 32_000;
@@ -59,8 +56,6 @@ describe('truncateStringToUtf8ByteBudget', () => {
result.value.replace(TRUNCATION_SENTINEL, ''), 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).toBeLessThanOrEqual(cap + 3);
expect(truncatedPayloadBytes).toBeGreaterThan(cap - 3); expect(truncatedPayloadBytes).toBeGreaterThan(cap - 3);
}); });
@@ -60,8 +60,6 @@ const httpRequestStepLogDetailsSchema = z.object({
bodyBytes: z.number().optional(), bodyBytes: z.number().optional(),
bodyTruncated: z.boolean().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 response: z
.object({ .object({
status: z.number(), status: z.number(),
@@ -130,11 +128,4 @@ export const workflowRunStepLogSchema = z.object({
sizeBytes: z.number(), 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()); export const workflowRunStepLogsSchema = z.record(z.string(), z.unknown());