Skip to content

Commit a4cd453

Browse files
committed
fix(logs): restore workflow input on log details for runs without secret provenance
PR #6000 added a resolved-secret gate to the log display projection: when a run's persisted resolvedSecretTraceProvenance is absent or incomplete, projectTraceSpansForSecrets returns structural-only spans, which stripped the whole display envelope. That silently blanked the Input and Output panels for every run written before #6000, and for any run whose registry goes incomplete. Splits the display envelope in two. Per-block content (finalOutput, blockInput, blockExecutions, errors, trace spans) keeps the existing gate and fails closed. Only workflowInput is exempted, via projectWorkflowBoundarySpansForSecrets: with complete provenance it is byte-identical to the current matcher path, and with absent or incomplete provenance the content survives instead of collapsing. workflowInput is exempt because an inbound trigger payload is captured before any secret is resolved. That premise does NOT hold for nested runs: a workflow or custom_block execution is handed workflowInput built from its parent's already-resolved block outputs, so those keep the gated treatment. The check is a denylist of nested trigger types, because webhook runs record the provider (zoho_desk, slack) as the trigger type and an allowlist would fail closed on exactly the population this fixes. Runs written before workflowInput was persisted recover it from the trigger block state, gated on an absent provenance key plus present block states, which together identify pre-stamping data without dating the row. Recovery refuses when more than one block state matches the trigger shape, since a paused run carries a resume placeholder with the same shape and its capability URL must never render as the workflow input. Verified against 137 executions on a live instance: the only display key that differs from the pre-fix baseline is workflowInput, on 72 runs, none lost.
1 parent 9200724 commit a4cd453

5 files changed

Lines changed: 564 additions & 39 deletions

File tree

apps/sim/lib/logs/execution/logging-session.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,28 @@ describe('LoggingSession terminal provenance', () => {
157157
)
158158
})
159159

160+
it('stamps provenance on finalization when no registry was installed explicitly', async () => {
161+
startWorkflowExecutionMock.mockResolvedValue({})
162+
loadWorkflowStateForExecutionMock.mockResolvedValue({
163+
blocks: {},
164+
edges: [],
165+
loops: {},
166+
parallels: {},
167+
})
168+
const session = new LoggingSession('workflow-1', 'execution-implicit-registry', 'webhook')
169+
170+
await session.start({ userId: 'user-1', workspaceId: 'workspace-1' })
171+
await session.completeWithError({ error: { message: 'failed' } })
172+
173+
expect(completeWorkflowExecutionMock).toHaveBeenCalledWith(
174+
expect.objectContaining({
175+
executionState: expect.objectContaining({
176+
resolvedSecretTraceProvenance: expect.objectContaining({ version: 1 }),
177+
}),
178+
})
179+
)
180+
})
181+
160182
it.each(['cancellation', 'pause'] as const)(
161183
'preserves raw execution state on %s finalization',
162184
async (finalization) => {

apps/sim/lib/logs/execution/trace-secret-projection.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({
1717
import {
1818
enforceTraceSpanSecretInvariant,
1919
projectTraceSpansForSecrets,
20+
projectWorkflowBoundarySpansForSecrets,
2021
} from '@/lib/logs/execution/trace-secret-projection'
2122
import type { TraceSpan } from '@/lib/logs/types'
2223
import {
@@ -1109,3 +1110,56 @@ describe('projectTraceSpansForSecrets', () => {
11091110
expect(cursor?.children).toEqual([])
11101111
})
11111112
})
1113+
1114+
describe('projectWorkflowBoundarySpansForSecrets', () => {
1115+
it('retains boundary content when provenance is absent', async () => {
1116+
const [result] = await projectWorkflowBoundarySpansForSecrets(
1117+
[createSpan({ output: { workflowInput: { channel: 'C123' } } })],
1118+
{ store: STORE }
1119+
)
1120+
1121+
expect(result.output).toEqual({ workflowInput: { channel: 'C123' } })
1122+
})
1123+
1124+
it('retains boundary content when provenance is incomplete', async () => {
1125+
const [result] = await projectWorkflowBoundarySpansForSecrets(
1126+
[createSpan({ output: { workflowInput: { channel: 'C123' } } })],
1127+
{ registry: createRegistry([], false), store: STORE }
1128+
)
1129+
1130+
expect(result.output).toEqual({ workflowInput: { channel: 'C123' } })
1131+
})
1132+
1133+
it('redacts resolved secrets when provenance is complete', async () => {
1134+
const [result] = await projectWorkflowBoundarySpansForSecrets(
1135+
[createSpan({ output: { workflowInput: { key: 'raw-secret' } } })],
1136+
{
1137+
registry: createRegistry([{ plaintext: 'raw-secret', replacement: '{{API_SECRET}}' }]),
1138+
store: STORE,
1139+
}
1140+
)
1141+
1142+
expect(result.output).toEqual({ workflowInput: { key: '{{API_SECRET}}' } })
1143+
})
1144+
})
1145+
1146+
describe('projectWorkflowBoundarySpansForSecrets structural fallback', () => {
1147+
it('falls back to structure when the bounded clone exceeds projection limits', async () => {
1148+
let source = createSpan({ id: 'depth-150', output: { workflowInput: { channel: 'C123' } } })
1149+
for (let depth = 149; depth >= 0; depth -= 1) {
1150+
source = createSpan({ id: `depth-${depth}`, children: [source] })
1151+
}
1152+
1153+
const [result] = await projectWorkflowBoundarySpansForSecrets([source], { store: STORE })
1154+
1155+
let projectedDepth = 0
1156+
let cursor: TraceSpan | undefined = result
1157+
while (cursor?.children?.[0]) {
1158+
expect(cursor).not.toHaveProperty('output')
1159+
projectedDepth += 1
1160+
cursor = cursor.children[0]
1161+
}
1162+
expect(projectedDepth).toBe(100)
1163+
expect(cursor).not.toHaveProperty('output')
1164+
})
1165+
})

apps/sim/lib/logs/execution/trace-secret-projection.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,37 @@ export async function enforceTraceSpanSecretInvariant(
15201520
}
15211521
}
15221522

1523+
/**
1524+
* Projects workflow-boundary spans - content captured before the executor
1525+
* resolves any secret, so it structurally cannot be a resolved-secret sink.
1526+
* Collapsing it to structure whenever provenance is unavailable leaves the log
1527+
* with nothing readable at all. With a complete registry this is identical to
1528+
* {@link projectTraceSpansForSecrets}; with absent or incomplete provenance the
1529+
* content is retained through the same clone used for a complete registry that
1530+
* holds no secrets. Anything downstream of block execution - including the
1531+
* workflow's final output - must not be routed here.
1532+
*
1533+
* That clone bounds span STRUCTURE (node count and depth) but not the payload
1534+
* inside `output`, which it keeps by reference. A caller must therefore only
1535+
* route content here that it is willing to return verbatim - the same exposure
1536+
* the no-secrets path already carries for every span it clones.
1537+
*/
1538+
export async function projectWorkflowBoundarySpansForSecrets(
1539+
traceSpans: TraceSpan[],
1540+
options: ProjectTraceSpansForSecretsOptions
1541+
): Promise<TraceSpan[]> {
1542+
if (options.registry?.isComplete()) return projectTraceSpansForSecrets(traceSpans, options)
1543+
1544+
try {
1545+
return cloneTraceSpansForProjection(traceSpans)
1546+
} catch {
1547+
logger.warn(
1548+
'Workflow-boundary projection exceeded structural limits; retaining structural spans only'
1549+
)
1550+
return structuralOnlyTraceSpans(traceSpans)
1551+
}
1552+
}
1553+
15231554
/**
15241555
* Produces the only Secrets-feature-safe representation of execution TraceSpans.
15251556
* Runtime logs and outputs remain untouched; only schema-defined trace content is copied.

0 commit comments

Comments
 (0)