From 0146274cabad201b4193bac1ccfebc9d739669a9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 20:36:08 -0700 Subject: [PATCH 1/4] fix(workflows): redact run and export secrets --- .../executor/execution-status.test.ts | 57 ++++++++++-- .../workflows/executor/execution-status.ts | 6 +- .../operations/export-workflow.test.ts | 91 +++++++++++++++++++ .../workflows/operations/export-workflow.ts | 4 +- .../workflows/sanitization/json-sanitizer.ts | 1 + 5 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 apps/sim/lib/workflows/operations/export-workflow.test.ts diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index 632a758ab83..1483fc7b48b 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -5,20 +5,17 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@s import { and } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetJob } = vi.hoisted(() => ({ +const { mockGetJob, mockMaterializeForDisplay } = vi.hoisted(() => ({ mockGetJob: vi.fn(), + mockMaterializeForDisplay: vi.fn(), })) vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), })) -vi.mock('@/lib/logs/execution/functional-outputs', () => ({ - collectFunctionalBlockOutputs: vi.fn().mockReturnValue(new Map()), -})) - vi.mock('@/lib/logs/execution/trace-store', () => ({ - materializeExecutionData: vi.fn(), + materializeExecutionDataForDisplay: mockMaterializeForDisplay, })) vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({ @@ -38,6 +35,54 @@ describe('getWorkflowExecutionStatus queue projection', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + mockMaterializeForDisplay.mockResolvedValue({}) + }) + + it('selects run outputs only from the secret-safe display projection', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + level: 'info', + trigger: 'api', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: new Date('2026-08-05T12:00:01.000Z'), + totalDurationMs: 1000, + executionData: { + executionState: { + blockStates: { 'block-1': { output: { token: 'resolved-secret' } } }, + }, + }, + costTotal: null, + }, + ]) + queueTableRows(schemaMock.resumeQueue, []) + queueTableRows(schemaMock.pausedExecutions, []) + mockMaterializeForDisplay.mockResolvedValueOnce({ + finalOutput: { token: '[REDACTED]' }, + traceSpans: [{ blockId: 'block-1', output: { token: '[REDACTED]' } }], + }) + const status = await getWorkflowExecutionStatus({ + ...input, + includeOutput: true, + selectedOutputs: ['block-1'], + }) + + expect(mockMaterializeForDisplay).toHaveBeenCalledWith( + expect.objectContaining({ executionState: expect.anything() }), + { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + } + ) + expect(status).toMatchObject({ + finalOutput: { token: '[REDACTED]' }, + blockOutputs: { 'block-1': { token: '[REDACTED]' } }, + }) + expect(JSON.stringify(status)).not.toContain('resolved-secret') }) it('projects a queued workflow job as an execution resource', async () => { diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index 363b57c1efa..1e58ead1a1a 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -8,7 +8,7 @@ import { collectFunctionalBlockOutputs, type FunctionalExecutionDataSource, } from '@/lib/logs/execution/functional-outputs' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' import { RESUME_EXECUTION_JOB_ID_PREFIX, WORKFLOW_EXECUTION_JOB_ID_PREFIX, @@ -259,9 +259,7 @@ export async function getWorkflowExecutionStatus( const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null - // Heavy execution data may live in object storage; resolve the pointer - // before reading error / finalOutput / traceSpans (no-op for inline rows). - const executionData = (await materializeExecutionData( + const executionData = (await materializeExecutionDataForDisplay( logRow.executionData as Record | null, { workspaceId: logRow.workspaceId, diff --git a/apps/sim/lib/workflows/operations/export-workflow.test.ts b/apps/sim/lib/workflows/operations/export-workflow.test.ts new file mode 100644 index 00000000000..18ca2c7abff --- /dev/null +++ b/apps/sim/lib/workflows/operations/export-workflow.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadNormalized: vi.fn(), +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mocks.loadNormalized, +})) + +vi.mock('@/blocks/registry', () => ({ + getBlock: (type: string) => + type === 'agent' + ? { + name: 'Agent', + subBlocks: [{ id: 'tools', type: 'tool-input' }], + outputs: {}, + } + : { + name: 'Slack', + subBlocks: [ + { id: 'credential', type: 'oauth-input' }, + { id: 'botToken', type: 'short-input', password: true }, + { id: 'text', type: 'long-input' }, + ], + outputs: {}, + }, +})) + +import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' + +describe('buildWorkflowExportPayload', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadNormalized.mockResolvedValue({ + blocks: { + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + position: { x: 0, y: 0 }, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'slack', + toolId: 'slack_message', + params: { + credential: 'nested-credential-id', + botToken: 'nested-xoxb-secret', + text: 'ordinary message', + }, + }, + ], + }, + }, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + }) + + it('redacts nested tool credentials from the public export payload', async () => { + const payload = await buildWorkflowExportPayload({ + id: 'workflow-1', + name: 'Reports', + description: null, + workspaceId: 'workspace-1', + folderId: null, + variables: {}, + }) + + const params = payload?.state.blocks.agent.subBlocks.tools.value[0].params + expect(params).toEqual({ + credential: null, + botToken: null, + text: 'ordinary message', + }) + expect(JSON.stringify(payload)).not.toContain('nested-credential-id') + expect(JSON.stringify(payload)).not.toContain('nested-xoxb-secret') + }) +}) diff --git a/apps/sim/lib/workflows/operations/export-workflow.ts b/apps/sim/lib/workflows/operations/export-workflow.ts index b347d9b3289..e2bfdd159a4 100644 --- a/apps/sim/lib/workflows/operations/export-workflow.ts +++ b/apps/sim/lib/workflows/operations/export-workflow.ts @@ -12,11 +12,13 @@ import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' * * Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits * the raw state for backup/restore, this runs the payload through - * `sanitizeForExport`, which nulls three classes of sub-block value: + * `sanitizeForExport`, which nulls five classes of sub-block value: * - `password: true` fields, unless the value is a whole `{{ENV_VAR}}` * reference, which is preserved so the import resolves it in the target * workspace; * - `oauth-input` credentials; + * - sensitive nested `tool-input` params and params without authoritative metadata; + * - opaque credential-bearing values such as arbitrary table cells; * - **workspace-scoped bindings** — selector fields and id-keyed fields that * point at rows that do not exist in another workspace, cleared rather than * carried across as dangling ids. diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 7be8dcee2c6..5a63706a447 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -662,6 +662,7 @@ export function sanitizeForExport(state: WorkflowState): ExportWorkflowState { // Use unified sanitization with env var preservation for export const sanitizedState = sanitizeWorkflowForSharing(fullState, { preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows + redactOpaqueCredentialInputs: true, }) as ExportWorkflowState['state'] return { From e92b6ab505f6acccdff112aeea9b4a308e4c9a40 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 20:51:06 -0700 Subject: [PATCH 2/4] fix(workflows): preserve redacted run outputs --- .../lib/logs/execution/trace-store.test.ts | 62 +++++++++ apps/sim/lib/logs/execution/trace-store.ts | 130 +++++++++++++++--- .../executor/execution-status.test.ts | 22 +-- .../workflows/executor/execution-status.ts | 21 +-- 4 files changed, 194 insertions(+), 41 deletions(-) diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index 926f519aa0b..8bc89662c18 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -21,6 +21,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({ import { externalizeExecutionData, materializeExecutionData, + materializeExecutionDataForDisplayWithBlockOutputs, projectExecutionDataForDisplay, RESOLVED_SECRET_PROVENANCE_KEY, SECRET_PROJECTION_VERSION, @@ -92,6 +93,67 @@ describe('execution data storage', () => { }) describe('projectExecutionDataForDisplay', () => { + it('projects authoritative state-only block outputs without mutating execution state', async () => { + const executionData = { + secretProjectionVersion: SECRET_PROJECTION_VERSION, + traceSpans: [], + executionState: { + resolvedSecretTraceProvenance: { + version: 1 as const, + complete: true, + entries: [{ name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + blockStates: { + 'function-1': { + output: { token: 12345678, derived: 12345683 }, + resolvedSecretTraceProvenance: { + version: 1 as const, + complete: true, + entries: [{ name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }, + }, + } + + const materialized = await materializeExecutionDataForDisplayWithBlockOutputs( + executionData, + CONTEXT, + ['function-1'] + ) + + expect(materialized.executionData).not.toHaveProperty('executionState') + expect(materialized.blockOutputs).toEqual( + new Map([['function-1', { token: '{{OPENAI_API_KEY}}', derived: 12345683 }]]) + ) + expect(executionData.executionState.blockStates['function-1'].output).toEqual({ + token: 12345678, + derived: 12345683, + }) + expect(JSON.stringify(materialized.executionData)).not.toContain('12345678') + expect(JSON.stringify([...materialized.blockOutputs])).not.toContain('12345678') + }) + + it('omits state-only block outputs that lack usable secret provenance', async () => { + const materialized = await materializeExecutionDataForDisplayWithBlockOutputs( + { + secretProjectionVersion: SECRET_PROJECTION_VERSION, + executionState: { + blockStates: { + 'function-1': { output: { token: 'unproven-secret' } }, + }, + }, + }, + CONTEXT, + ['function-1'] + ) + + expect(materialized.blockOutputs).toEqual(new Map()) + expect(JSON.stringify(materialized)).not.toContain('unproven-secret') + }) + it('retains run-global projection for legacy rows without exact value sidecars', async () => { const executionData = { finalOutput: { result: 12345678, derived: 12345683 }, diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index 10b8d6eaa1d..9b8bd8e98ae 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -3,6 +3,10 @@ import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store' +import { + collectFunctionalBlockOutputs, + type FunctionalExecutionDataSource, +} from '@/lib/logs/execution/functional-outputs' import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection' import type { TraceSpan } from '@/lib/logs/types' import { @@ -72,6 +76,11 @@ export interface TraceStoreReadContext { userId?: string } +export interface DisplayExecutionDataWithBlockOutputs { + executionData: Record + blockOutputs: Map +} + /** * Write-path context. Requires the execution owner's `userId`: the externalized * object is tracked in `workspace_files`, whose `user_id` column is NOT NULL @@ -269,6 +278,102 @@ export async function materializeExecutionDataForDisplay( return projectExecutionDataForDisplay(materialized, context) } +/** + * Materializes one trusted row into its display envelope plus secret-safe functional outputs. + * Execution-state output remains authoritative when present, but only requested blocks are + * projected and returned; the raw execution state never crosses the display boundary. + */ +export async function materializeExecutionDataForDisplayWithBlockOutputs( + executionData: Record | null | undefined, + context: TraceStoreReadContext, + blockIds: readonly string[] +): Promise { + const materialized = await materializeExecutionData(executionData, context) + const displayData = await projectExecutionDataForDisplay(materialized, context) + if (blockIds.length === 0) { + return { executionData: displayData, blockOutputs: new Map() } + } + + const executionState = readRecord(materialized.executionState) + const blockStates = readRecord(executionState?.blockStates) + if (!blockStates) { + return { + executionData: displayData, + blockOutputs: collectFunctionalBlockOutputs( + displayData as FunctionalExecutionDataSource | undefined + ), + } + } + + const runRegistry = await importResolvedSecretTraceRegistry( + materialized[RESOLVED_SECRET_PROVENANCE_KEY] ?? + executionState?.[RESOLVED_SECRET_PROVENANCE_KEY], + 'traceStore.blockOutputRunProvenance' + ) + const blockOutputs = new Map() + const projectionStore = createReadOnlyProjectionStore(context) + + for (const blockId of new Set(blockIds)) { + const blockState = readRecord(blockStates[blockId]) + if (!blockState || blockState.output === undefined) continue + + const hasExactProvenance = Object.hasOwn(blockState, RESOLVED_SECRET_PROVENANCE_KEY) + const registry = hasExactProvenance + ? await importResolvedSecretTraceRegistry( + blockState[RESOLVED_SECRET_PROVENANCE_KEY], + 'traceStore.blockOutputExactProvenance' + ) + : runRegistry + const now = new Date().toISOString() + const [projected] = await projectTraceSpansForSecrets( + [ + { + id: `${LOG_DISPLAY_PROJECTION_SPAN_ID}-block-output`, + name: 'Block Output Display Projection', + type: 'display', + duration: 0, + startTime: now, + endTime: now, + output: { value: blockState.output }, + }, + ], + { registry, allowLargeValueWrites: false, store: projectionStore } + ) + if (projected?.output && Object.hasOwn(projected.output, 'value')) { + blockOutputs.set(blockId, projected.output.value) + } + } + + return { executionData: displayData, blockOutputs } +} + +function readRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +async function importResolvedSecretTraceRegistry( + provenance: unknown, + origin: string +): Promise { + if (!isResolvedSecretTraceProvenanceV1(provenance)) return undefined + + const registry = new ResolvedSecretTraceRegistry([], provenance.scope) + await registry.importProvenance(provenance, { trusted: true, origin }) + return registry +} + +function createReadOnlyProjectionStore(context: TraceStoreReadContext) { + return { + workspaceId: context.workspaceId ?? undefined, + workflowId: context.workflowId ?? undefined, + executionId: context.executionId, + userId: context.userId, + trackReference: false, + } +} + /** * Projects execution-log content with the encrypted provenance saved by the * trusted executor. Current workflow input and final output values use their @@ -284,12 +389,7 @@ export async function projectExecutionDataForDisplay( executionData: Record, context: TraceStoreReadContext ): Promise> { - const executionState = - executionData.executionState && - typeof executionData.executionState === 'object' && - !Array.isArray(executionData.executionState) - ? (executionData.executionState as Record) - : undefined + const executionState = readRecord(executionData.executionState) const hasTopLevelProvenance = Object.hasOwn(executionData, RESOLVED_SECRET_PROVENANCE_KEY) const stateProvenance = executionState?.[RESOLVED_SECRET_PROVENANCE_KEY] const provenance = executionData[RESOLVED_SECRET_PROVENANCE_KEY] ?? stateProvenance @@ -302,15 +402,7 @@ export async function projectExecutionDataForDisplay( return projectLegacyExecutionDataForDisplay(executionData) } - let registry: ResolvedSecretTraceRegistry | undefined - - if (isResolvedSecretTraceProvenanceV1(provenance)) { - registry = new ResolvedSecretTraceRegistry([], provenance.scope) - await registry.importProvenance(provenance, { - trusted: true, - origin: 'traceStore.spanProvenance', - }) - } + const registry = await importResolvedSecretTraceRegistry(provenance, 'traceStore.spanProvenance') /** * Compaction drops `executionState`, and with it the only copy of the @@ -339,13 +431,7 @@ export async function projectExecutionDataForDisplay( }) } - const projectionStore = { - workspaceId: context.workspaceId ?? undefined, - workflowId: context.workflowId ?? undefined, - executionId: context.executionId, - userId: context.userId, - trackReference: false, - } + const projectionStore = createReadOnlyProjectionStore(context) const exactValueProjections = new Map() for (const [valueKey, provenanceKey] of Object.entries(EXACT_LOG_VALUE_PROVENANCE_KEYS)) { diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index 1483fc7b48b..5996631349e 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -5,9 +5,9 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@s import { and } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetJob, mockMaterializeForDisplay } = vi.hoisted(() => ({ +const { mockGetJob, mockMaterializeForDisplayWithBlockOutputs } = vi.hoisted(() => ({ mockGetJob: vi.fn(), - mockMaterializeForDisplay: vi.fn(), + mockMaterializeForDisplayWithBlockOutputs: vi.fn(), })) vi.mock('@/lib/core/async-jobs', () => ({ @@ -15,7 +15,7 @@ vi.mock('@/lib/core/async-jobs', () => ({ })) vi.mock('@/lib/logs/execution/trace-store', () => ({ - materializeExecutionDataForDisplay: mockMaterializeForDisplay, + materializeExecutionDataForDisplayWithBlockOutputs: mockMaterializeForDisplayWithBlockOutputs, })) vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({ @@ -35,7 +35,10 @@ describe('getWorkflowExecutionStatus queue projection', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockMaterializeForDisplay.mockResolvedValue({}) + mockMaterializeForDisplayWithBlockOutputs.mockResolvedValue({ + executionData: {}, + blockOutputs: new Map(), + }) }) it('selects run outputs only from the secret-safe display projection', async () => { @@ -60,9 +63,9 @@ describe('getWorkflowExecutionStatus queue projection', () => { ]) queueTableRows(schemaMock.resumeQueue, []) queueTableRows(schemaMock.pausedExecutions, []) - mockMaterializeForDisplay.mockResolvedValueOnce({ - finalOutput: { token: '[REDACTED]' }, - traceSpans: [{ blockId: 'block-1', output: { token: '[REDACTED]' } }], + mockMaterializeForDisplayWithBlockOutputs.mockResolvedValueOnce({ + executionData: { finalOutput: { token: '[REDACTED]' } }, + blockOutputs: new Map([['block-1', { token: '[REDACTED]' }]]), }) const status = await getWorkflowExecutionStatus({ ...input, @@ -70,13 +73,14 @@ describe('getWorkflowExecutionStatus queue projection', () => { selectedOutputs: ['block-1'], }) - expect(mockMaterializeForDisplay).toHaveBeenCalledWith( + expect(mockMaterializeForDisplayWithBlockOutputs).toHaveBeenCalledWith( expect.objectContaining({ executionState: expect.anything() }), { workspaceId: 'workspace-1', workflowId: 'workflow-1', executionId: 'execution-1', - } + }, + ['block-1'] ) expect(status).toMatchObject({ finalOutput: { token: '[REDACTED]' }, diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index 1e58ead1a1a..bc6cf1370df 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -4,11 +4,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm' import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows' import { getJobQueue } from '@/lib/core/async-jobs' import type { Job } from '@/lib/core/async-jobs/types' -import { - collectFunctionalBlockOutputs, - type FunctionalExecutionDataSource, -} from '@/lib/logs/execution/functional-outputs' -import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { materializeExecutionDataForDisplayWithBlockOutputs } from '@/lib/logs/execution/trace-store' import { RESUME_EXECUTION_JOB_ID_PREFIX, WORKFLOW_EXECUTION_JOB_ID_PREFIX, @@ -27,7 +23,7 @@ import type { PausePoint } from '@/executor/types' type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' -interface ExecutionDataShape extends FunctionalExecutionDataSource { +interface ExecutionDataShape { finalOutput?: { error?: string } & Record error?: { message?: string } | string completionFailure?: string @@ -259,14 +255,19 @@ export async function getWorkflowExecutionStatus( const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null - const executionData = (await materializeExecutionDataForDisplay( + const requestedBlockIds = [ + ...new Set(selectedOutputs.map((selector) => selector.split('.')[0]).filter(Boolean)), + ] + const materialized = await materializeExecutionDataForDisplayWithBlockOutputs( logRow.executionData as Record | null, { workspaceId: logRow.workspaceId, workflowId: logRow.workflowId, executionId: logRow.executionId, - } - )) as ExecutionDataShape | undefined + }, + requestedBlockIds + ) + const executionData = materialized.executionData as ExecutionDataShape const error = status === 'failed' ? extractError(executionData) : null @@ -277,7 +278,7 @@ export async function getWorkflowExecutionStatus( const blockOutputs = selectedOutputs.length > 0 - ? pickSelectedOutputs(selectedOutputs, collectFunctionalBlockOutputs(executionData)) + ? pickSelectedOutputs(selectedOutputs, materialized.blockOutputs) : null return { From 2936dfc384f0a32a2f38d6d5300a6362f78f9ba2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 21:07:37 -0700 Subject: [PATCH 3/4] fix(workflows): retain safe trace output fallback --- .../lib/logs/execution/trace-store.test.ts | 56 +++++++++++++++++++ apps/sim/lib/logs/execution/trace-store.ts | 8 +-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index 8bc89662c18..165d04c83ac 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -136,10 +136,66 @@ describe('projectExecutionDataForDisplay', () => { expect(JSON.stringify([...materialized.blockOutputs])).not.toContain('12345678') }) + it('falls back to projected trace output for a requested block missing from partial state', async () => { + const emptyProvenance = { + version: 1 as const, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + } + const materialized = await materializeExecutionDataForDisplayWithBlockOutputs( + { + secretProjectionVersion: SECRET_PROJECTION_VERSION, + traceSpans: [ + { + id: 'span-1', + blockId: 'trace-only', + name: 'Trace-only block', + type: 'function', + duration: 1, + startTime: '2026-08-11T00:00:00.000Z', + endTime: '2026-08-11T00:00:00.001Z', + output: { result: 'trace-output' }, + }, + ], + executionState: { + resolvedSecretTraceProvenance: emptyProvenance, + blockStates: { + 'state-only': { + output: { result: 'state-output' }, + resolvedSecretTraceProvenance: emptyProvenance, + }, + }, + }, + }, + CONTEXT, + ['state-only', 'trace-only'] + ) + + expect(materialized.blockOutputs).toEqual( + new Map([ + ['trace-only', { result: 'trace-output' }], + ['state-only', { result: 'state-output' }], + ]) + ) + }) + it('omits state-only block outputs that lack usable secret provenance', async () => { const materialized = await materializeExecutionDataForDisplayWithBlockOutputs( { secretProjectionVersion: SECRET_PROJECTION_VERSION, + traceSpans: [ + { + id: 'span-1', + blockId: 'function-1', + name: 'Function 1', + type: 'function', + duration: 1, + startTime: '2026-08-11T00:00:00.000Z', + endTime: '2026-08-11T00:00:00.001Z', + output: { token: 'trace-fallback' }, + }, + ], executionState: { blockStates: { 'function-1': { output: { token: 'unproven-secret' } }, diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index 9b8bd8e98ae..2271c18b60b 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -296,12 +296,11 @@ export async function materializeExecutionDataForDisplayWithBlockOutputs( const executionState = readRecord(materialized.executionState) const blockStates = readRecord(executionState?.blockStates) + const displaySource = displayData as FunctionalExecutionDataSource if (!blockStates) { return { executionData: displayData, - blockOutputs: collectFunctionalBlockOutputs( - displayData as FunctionalExecutionDataSource | undefined - ), + blockOutputs: collectFunctionalBlockOutputs(displaySource), } } @@ -310,13 +309,14 @@ export async function materializeExecutionDataForDisplayWithBlockOutputs( executionState?.[RESOLVED_SECRET_PROVENANCE_KEY], 'traceStore.blockOutputRunProvenance' ) - const blockOutputs = new Map() + const blockOutputs = collectFunctionalBlockOutputs({ traceSpans: displaySource.traceSpans }) const projectionStore = createReadOnlyProjectionStore(context) for (const blockId of new Set(blockIds)) { const blockState = readRecord(blockStates[blockId]) if (!blockState || blockState.output === undefined) continue + blockOutputs.delete(blockId) const hasExactProvenance = Object.hasOwn(blockState, RESOLVED_SECRET_PROVENANCE_KEY) const registry = hasExactProvenance ? await importResolvedSecretTraceRegistry( From 08c27c38cbf2c3b6b15ad46af97635a5083ffb40 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 21:25:37 -0700 Subject: [PATCH 4/4] fix(workflows): stop deriving outputs from traces --- .../lib/logs/execution/trace-store.test.ts | 59 +++++++++++++++++-- apps/sim/lib/logs/execution/trace-store.ts | 19 +++--- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index 165d04c83ac..f6afaf68b1d 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -136,7 +136,7 @@ describe('projectExecutionDataForDisplay', () => { expect(JSON.stringify([...materialized.blockOutputs])).not.toContain('12345678') }) - it('falls back to projected trace output for a requested block missing from partial state', async () => { + it('does not use trace output for a requested block missing from partial state', async () => { const emptyProvenance = { version: 1 as const, complete: true, @@ -172,12 +172,59 @@ describe('projectExecutionDataForDisplay', () => { ['state-only', 'trace-only'] ) - expect(materialized.blockOutputs).toEqual( - new Map([ - ['trace-only', { result: 'trace-output' }], - ['state-only', { result: 'state-output' }], - ]) + expect(materialized.blockOutputs).toEqual(new Map([['state-only', { result: 'state-output' }]])) + }) + + it('does not derive block outputs from legacy trace spans', async () => { + const materialized = await materializeExecutionDataForDisplayWithBlockOutputs( + { + traceSpans: [ + { + id: 'span-1', + blockId: 'function-1', + name: 'Function 1', + type: 'function', + duration: 1, + startTime: '2026-08-11T00:00:00.000Z', + endTime: '2026-08-11T00:00:00.001Z', + output: { token: 'raw-legacy-secret' }, + }, + ], + }, + CONTEXT, + ['function-1'] + ) + + expect(materialized.blockOutputs).toEqual(new Map()) + }) + + it('does not mix legacy trace output into partial execution state', async () => { + const materialized = await materializeExecutionDataForDisplayWithBlockOutputs( + { + traceSpans: [ + { + id: 'span-1', + blockId: 'trace-only', + name: 'Trace-only block', + type: 'function', + duration: 1, + startTime: '2026-08-11T00:00:00.000Z', + endTime: '2026-08-11T00:00:00.001Z', + output: { token: 'raw-legacy-secret' }, + }, + ], + executionState: { + blockStates: { + 'state-only': { output: { result: 'unproven-state-output' } }, + }, + }, + }, + CONTEXT, + ['state-only', 'trace-only'] ) + + expect(materialized.blockOutputs).toEqual(new Map()) + expect(JSON.stringify([...materialized.blockOutputs])).not.toContain('raw-legacy-secret') }) it('omits state-only block outputs that lack usable secret provenance', async () => { diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index 2271c18b60b..38d62dcdf8c 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -3,10 +3,7 @@ import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store' -import { - collectFunctionalBlockOutputs, - type FunctionalExecutionDataSource, -} from '@/lib/logs/execution/functional-outputs' +import { FunctionalOutputsUnavailableError } from '@/lib/logs/execution/functional-outputs' import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection' import type { TraceSpan } from '@/lib/logs/types' import { @@ -280,8 +277,8 @@ export async function materializeExecutionDataForDisplay( /** * Materializes one trusted row into its display envelope plus secret-safe functional outputs. - * Execution-state output remains authoritative when present, but only requested blocks are - * projected and returned; the raw execution state never crosses the display boundary. + * Only requested execution-state outputs are projected and returned; trace spans remain display + * data and the raw execution state never crosses the display boundary. */ export async function materializeExecutionDataForDisplayWithBlockOutputs( executionData: Record | null | undefined, @@ -296,12 +293,11 @@ export async function materializeExecutionDataForDisplayWithBlockOutputs( const executionState = readRecord(materialized.executionState) const blockStates = readRecord(executionState?.blockStates) - const displaySource = displayData as FunctionalExecutionDataSource if (!blockStates) { - return { - executionData: displayData, - blockOutputs: collectFunctionalBlockOutputs(displaySource), + if (materialized.executionDataTruncated === true) { + throw new FunctionalOutputsUnavailableError() } + return { executionData: displayData, blockOutputs: new Map() } } const runRegistry = await importResolvedSecretTraceRegistry( @@ -309,14 +305,13 @@ export async function materializeExecutionDataForDisplayWithBlockOutputs( executionState?.[RESOLVED_SECRET_PROVENANCE_KEY], 'traceStore.blockOutputRunProvenance' ) - const blockOutputs = collectFunctionalBlockOutputs({ traceSpans: displaySource.traceSpans }) + const blockOutputs = new Map() const projectionStore = createReadOnlyProjectionStore(context) for (const blockId of new Set(blockIds)) { const blockState = readRecord(blockStates[blockId]) if (!blockState || blockState.output === undefined) continue - blockOutputs.delete(blockId) const hasExactProvenance = Object.hasOwn(blockState, RESOLVED_SECRET_PROVENANCE_KEY) const registry = hasExactProvenance ? await importResolvedSecretTraceRegistry(