Skip to content

Commit e92b6ab

Browse files
fix(workflows): preserve redacted run outputs
1 parent 0146274 commit e92b6ab

4 files changed

Lines changed: 194 additions & 41 deletions

File tree

apps/sim/lib/logs/execution/trace-store.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({
2121
import {
2222
externalizeExecutionData,
2323
materializeExecutionData,
24+
materializeExecutionDataForDisplayWithBlockOutputs,
2425
projectExecutionDataForDisplay,
2526
RESOLVED_SECRET_PROVENANCE_KEY,
2627
SECRET_PROJECTION_VERSION,
@@ -92,6 +93,67 @@ describe('execution data storage', () => {
9293
})
9394

9495
describe('projectExecutionDataForDisplay', () => {
96+
it('projects authoritative state-only block outputs without mutating execution state', async () => {
97+
const executionData = {
98+
secretProjectionVersion: SECRET_PROJECTION_VERSION,
99+
traceSpans: [],
100+
executionState: {
101+
resolvedSecretTraceProvenance: {
102+
version: 1 as const,
103+
complete: true,
104+
entries: [{ name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }],
105+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
106+
},
107+
blockStates: {
108+
'function-1': {
109+
output: { token: 12345678, derived: 12345683 },
110+
resolvedSecretTraceProvenance: {
111+
version: 1 as const,
112+
complete: true,
113+
entries: [{ name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }],
114+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
115+
},
116+
},
117+
},
118+
},
119+
}
120+
121+
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
122+
executionData,
123+
CONTEXT,
124+
['function-1']
125+
)
126+
127+
expect(materialized.executionData).not.toHaveProperty('executionState')
128+
expect(materialized.blockOutputs).toEqual(
129+
new Map([['function-1', { token: '{{OPENAI_API_KEY}}', derived: 12345683 }]])
130+
)
131+
expect(executionData.executionState.blockStates['function-1'].output).toEqual({
132+
token: 12345678,
133+
derived: 12345683,
134+
})
135+
expect(JSON.stringify(materialized.executionData)).not.toContain('12345678')
136+
expect(JSON.stringify([...materialized.blockOutputs])).not.toContain('12345678')
137+
})
138+
139+
it('omits state-only block outputs that lack usable secret provenance', async () => {
140+
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
141+
{
142+
secretProjectionVersion: SECRET_PROJECTION_VERSION,
143+
executionState: {
144+
blockStates: {
145+
'function-1': { output: { token: 'unproven-secret' } },
146+
},
147+
},
148+
},
149+
CONTEXT,
150+
['function-1']
151+
)
152+
153+
expect(materialized.blockOutputs).toEqual(new Map())
154+
expect(JSON.stringify(materialized)).not.toContain('unproven-secret')
155+
})
156+
95157
it('retains run-global projection for legacy rows without exact value sidecars', async () => {
96158
const executionData = {
97159
finalOutput: { result: 12345678, derived: 12345683 },

apps/sim/lib/logs/execution/trace-store.ts

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { toError } from '@sim/utils/errors'
33
import { omit } from '@sim/utils/object'
44
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
55
import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store'
6+
import {
7+
collectFunctionalBlockOutputs,
8+
type FunctionalExecutionDataSource,
9+
} from '@/lib/logs/execution/functional-outputs'
610
import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection'
711
import type { TraceSpan } from '@/lib/logs/types'
812
import {
@@ -72,6 +76,11 @@ export interface TraceStoreReadContext {
7276
userId?: string
7377
}
7478

79+
export interface DisplayExecutionDataWithBlockOutputs {
80+
executionData: Record<string, unknown>
81+
blockOutputs: Map<string, unknown>
82+
}
83+
7584
/**
7685
* Write-path context. Requires the execution owner's `userId`: the externalized
7786
* object is tracked in `workspace_files`, whose `user_id` column is NOT NULL
@@ -269,6 +278,102 @@ export async function materializeExecutionDataForDisplay(
269278
return projectExecutionDataForDisplay(materialized, context)
270279
}
271280

281+
/**
282+
* Materializes one trusted row into its display envelope plus secret-safe functional outputs.
283+
* Execution-state output remains authoritative when present, but only requested blocks are
284+
* projected and returned; the raw execution state never crosses the display boundary.
285+
*/
286+
export async function materializeExecutionDataForDisplayWithBlockOutputs(
287+
executionData: Record<string, unknown> | null | undefined,
288+
context: TraceStoreReadContext,
289+
blockIds: readonly string[]
290+
): Promise<DisplayExecutionDataWithBlockOutputs> {
291+
const materialized = await materializeExecutionData(executionData, context)
292+
const displayData = await projectExecutionDataForDisplay(materialized, context)
293+
if (blockIds.length === 0) {
294+
return { executionData: displayData, blockOutputs: new Map() }
295+
}
296+
297+
const executionState = readRecord(materialized.executionState)
298+
const blockStates = readRecord(executionState?.blockStates)
299+
if (!blockStates) {
300+
return {
301+
executionData: displayData,
302+
blockOutputs: collectFunctionalBlockOutputs(
303+
displayData as FunctionalExecutionDataSource | undefined
304+
),
305+
}
306+
}
307+
308+
const runRegistry = await importResolvedSecretTraceRegistry(
309+
materialized[RESOLVED_SECRET_PROVENANCE_KEY] ??
310+
executionState?.[RESOLVED_SECRET_PROVENANCE_KEY],
311+
'traceStore.blockOutputRunProvenance'
312+
)
313+
const blockOutputs = new Map<string, unknown>()
314+
const projectionStore = createReadOnlyProjectionStore(context)
315+
316+
for (const blockId of new Set(blockIds)) {
317+
const blockState = readRecord(blockStates[blockId])
318+
if (!blockState || blockState.output === undefined) continue
319+
320+
const hasExactProvenance = Object.hasOwn(blockState, RESOLVED_SECRET_PROVENANCE_KEY)
321+
const registry = hasExactProvenance
322+
? await importResolvedSecretTraceRegistry(
323+
blockState[RESOLVED_SECRET_PROVENANCE_KEY],
324+
'traceStore.blockOutputExactProvenance'
325+
)
326+
: runRegistry
327+
const now = new Date().toISOString()
328+
const [projected] = await projectTraceSpansForSecrets(
329+
[
330+
{
331+
id: `${LOG_DISPLAY_PROJECTION_SPAN_ID}-block-output`,
332+
name: 'Block Output Display Projection',
333+
type: 'display',
334+
duration: 0,
335+
startTime: now,
336+
endTime: now,
337+
output: { value: blockState.output },
338+
},
339+
],
340+
{ registry, allowLargeValueWrites: false, store: projectionStore }
341+
)
342+
if (projected?.output && Object.hasOwn(projected.output, 'value')) {
343+
blockOutputs.set(blockId, projected.output.value)
344+
}
345+
}
346+
347+
return { executionData: displayData, blockOutputs }
348+
}
349+
350+
function readRecord(value: unknown): Record<string, unknown> | undefined {
351+
return value && typeof value === 'object' && !Array.isArray(value)
352+
? (value as Record<string, unknown>)
353+
: undefined
354+
}
355+
356+
async function importResolvedSecretTraceRegistry(
357+
provenance: unknown,
358+
origin: string
359+
): Promise<ResolvedSecretTraceRegistry | undefined> {
360+
if (!isResolvedSecretTraceProvenanceV1(provenance)) return undefined
361+
362+
const registry = new ResolvedSecretTraceRegistry([], provenance.scope)
363+
await registry.importProvenance(provenance, { trusted: true, origin })
364+
return registry
365+
}
366+
367+
function createReadOnlyProjectionStore(context: TraceStoreReadContext) {
368+
return {
369+
workspaceId: context.workspaceId ?? undefined,
370+
workflowId: context.workflowId ?? undefined,
371+
executionId: context.executionId,
372+
userId: context.userId,
373+
trackReference: false,
374+
}
375+
}
376+
272377
/**
273378
* Projects execution-log content with the encrypted provenance saved by the
274379
* trusted executor. Current workflow input and final output values use their
@@ -284,12 +389,7 @@ export async function projectExecutionDataForDisplay(
284389
executionData: Record<string, unknown>,
285390
context: TraceStoreReadContext
286391
): Promise<Record<string, unknown>> {
287-
const executionState =
288-
executionData.executionState &&
289-
typeof executionData.executionState === 'object' &&
290-
!Array.isArray(executionData.executionState)
291-
? (executionData.executionState as Record<string, unknown>)
292-
: undefined
392+
const executionState = readRecord(executionData.executionState)
293393
const hasTopLevelProvenance = Object.hasOwn(executionData, RESOLVED_SECRET_PROVENANCE_KEY)
294394
const stateProvenance = executionState?.[RESOLVED_SECRET_PROVENANCE_KEY]
295395
const provenance = executionData[RESOLVED_SECRET_PROVENANCE_KEY] ?? stateProvenance
@@ -302,15 +402,7 @@ export async function projectExecutionDataForDisplay(
302402
return projectLegacyExecutionDataForDisplay(executionData)
303403
}
304404

305-
let registry: ResolvedSecretTraceRegistry | undefined
306-
307-
if (isResolvedSecretTraceProvenanceV1(provenance)) {
308-
registry = new ResolvedSecretTraceRegistry([], provenance.scope)
309-
await registry.importProvenance(provenance, {
310-
trusted: true,
311-
origin: 'traceStore.spanProvenance',
312-
})
313-
}
405+
const registry = await importResolvedSecretTraceRegistry(provenance, 'traceStore.spanProvenance')
314406

315407
/**
316408
* Compaction drops `executionState`, and with it the only copy of the
@@ -339,13 +431,7 @@ export async function projectExecutionDataForDisplay(
339431
})
340432
}
341433

342-
const projectionStore = {
343-
workspaceId: context.workspaceId ?? undefined,
344-
workflowId: context.workflowId ?? undefined,
345-
executionId: context.executionId,
346-
userId: context.userId,
347-
trackReference: false,
348-
}
434+
const projectionStore = createReadOnlyProjectionStore(context)
349435

350436
const exactValueProjections = new Map<string, unknown>()
351437
for (const [valueKey, provenanceKey] of Object.entries(EXACT_LOG_VALUE_PROVENANCE_KEYS)) {

apps/sim/lib/workflows/executor/execution-status.test.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@s
55
import { and } from 'drizzle-orm'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockGetJob, mockMaterializeForDisplay } = vi.hoisted(() => ({
8+
const { mockGetJob, mockMaterializeForDisplayWithBlockOutputs } = vi.hoisted(() => ({
99
mockGetJob: vi.fn(),
10-
mockMaterializeForDisplay: vi.fn(),
10+
mockMaterializeForDisplayWithBlockOutputs: vi.fn(),
1111
}))
1212

1313
vi.mock('@/lib/core/async-jobs', () => ({
1414
getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }),
1515
}))
1616

1717
vi.mock('@/lib/logs/execution/trace-store', () => ({
18-
materializeExecutionDataForDisplay: mockMaterializeForDisplay,
18+
materializeExecutionDataForDisplayWithBlockOutputs: mockMaterializeForDisplayWithBlockOutputs,
1919
}))
2020

2121
vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({
@@ -35,7 +35,10 @@ describe('getWorkflowExecutionStatus queue projection', () => {
3535
beforeEach(() => {
3636
vi.clearAllMocks()
3737
resetDbChainMock()
38-
mockMaterializeForDisplay.mockResolvedValue({})
38+
mockMaterializeForDisplayWithBlockOutputs.mockResolvedValue({
39+
executionData: {},
40+
blockOutputs: new Map(),
41+
})
3942
})
4043

4144
it('selects run outputs only from the secret-safe display projection', async () => {
@@ -60,23 +63,24 @@ describe('getWorkflowExecutionStatus queue projection', () => {
6063
])
6164
queueTableRows(schemaMock.resumeQueue, [])
6265
queueTableRows(schemaMock.pausedExecutions, [])
63-
mockMaterializeForDisplay.mockResolvedValueOnce({
64-
finalOutput: { token: '[REDACTED]' },
65-
traceSpans: [{ blockId: 'block-1', output: { token: '[REDACTED]' } }],
66+
mockMaterializeForDisplayWithBlockOutputs.mockResolvedValueOnce({
67+
executionData: { finalOutput: { token: '[REDACTED]' } },
68+
blockOutputs: new Map([['block-1', { token: '[REDACTED]' }]]),
6669
})
6770
const status = await getWorkflowExecutionStatus({
6871
...input,
6972
includeOutput: true,
7073
selectedOutputs: ['block-1'],
7174
})
7275

73-
expect(mockMaterializeForDisplay).toHaveBeenCalledWith(
76+
expect(mockMaterializeForDisplayWithBlockOutputs).toHaveBeenCalledWith(
7477
expect.objectContaining({ executionState: expect.anything() }),
7578
{
7679
workspaceId: 'workspace-1',
7780
workflowId: 'workflow-1',
7881
executionId: 'execution-1',
79-
}
82+
},
83+
['block-1']
8084
)
8185
expect(status).toMatchObject({
8286
finalOutput: { token: '[REDACTED]' },

apps/sim/lib/workflows/executor/execution-status.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm'
44
import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows'
55
import { getJobQueue } from '@/lib/core/async-jobs'
66
import type { Job } from '@/lib/core/async-jobs/types'
7-
import {
8-
collectFunctionalBlockOutputs,
9-
type FunctionalExecutionDataSource,
10-
} from '@/lib/logs/execution/functional-outputs'
11-
import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store'
7+
import { materializeExecutionDataForDisplayWithBlockOutputs } from '@/lib/logs/execution/trace-store'
128
import {
139
RESUME_EXECUTION_JOB_ID_PREFIX,
1410
WORKFLOW_EXECUTION_JOB_ID_PREFIX,
@@ -27,7 +23,7 @@ import type { PausePoint } from '@/executor/types'
2723

2824
type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
2925

30-
interface ExecutionDataShape extends FunctionalExecutionDataSource {
26+
interface ExecutionDataShape {
3127
finalOutput?: { error?: string } & Record<string, unknown>
3228
error?: { message?: string } | string
3329
completionFailure?: string
@@ -259,14 +255,19 @@ export async function getWorkflowExecutionStatus(
259255

260256
const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null
261257

262-
const executionData = (await materializeExecutionDataForDisplay(
258+
const requestedBlockIds = [
259+
...new Set(selectedOutputs.map((selector) => selector.split('.')[0]).filter(Boolean)),
260+
]
261+
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
263262
logRow.executionData as Record<string, unknown> | null,
264263
{
265264
workspaceId: logRow.workspaceId,
266265
workflowId: logRow.workflowId,
267266
executionId: logRow.executionId,
268-
}
269-
)) as ExecutionDataShape | undefined
267+
},
268+
requestedBlockIds
269+
)
270+
const executionData = materialized.executionData as ExecutionDataShape
270271

271272
const error = status === 'failed' ? extractError(executionData) : null
272273

@@ -277,7 +278,7 @@ export async function getWorkflowExecutionStatus(
277278

278279
const blockOutputs =
279280
selectedOutputs.length > 0
280-
? pickSelectedOutputs(selectedOutputs, collectFunctionalBlockOutputs(executionData))
281+
? pickSelectedOutputs(selectedOutputs, materialized.blockOutputs)
281282
: null
282283

283284
return {

0 commit comments

Comments
 (0)