diff --git a/apps/sim/executor/execution/engine.test.ts b/apps/sim/executor/execution/engine.test.ts index 611bfa32f74..2e4d4291528 100644 --- a/apps/sim/executor/execution/engine.test.ts +++ b/apps/sim/executor/execution/engine.test.ts @@ -233,6 +233,81 @@ describe('ExecutionEngine', () => { expect(result.executionState?.finalOutputResolvedSecretTraceProvenance?.entries).toEqual([]) }) + /** + * A subflow sentinel stores its aggregate without provenance, and a loop that ran no + * iterations has none to merge. Treating that absence as a verdict marked the run + * unvouchable, which withheld the user's own final output from their execution log on every + * later view. + */ + it('derives final output provenance when the last block state carries none', async () => { + const node = createMockNode('loop-1', 'loop') + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'secret-value-1234', encryptedValue: 'ciphertext' }, + ]) + registry.recordResolved('TOKEN', 'secret-value-1234') + const context = createMockContext({ + decisions: { router: new Map(), condition: new Map() }, + resolvedSecretTraceRegistry: registry, + }) + const nodeOrchestrator = createMockNodeOrchestrator() + vi.mocked(nodeOrchestrator.executeNode).mockResolvedValue({ + nodeId: node.id, + output: { results: ['secret-value-1234'] }, + isFinalOutput: true, + }) + vi.mocked(nodeOrchestrator.handleNodeCompletion).mockImplementation( + (_ctx, nodeId, output) => { + context.blockStates.set(nodeId, { output, executed: true, executionTime: 1 }) + } + ) + + const engine = new ExecutionEngine( + context, + createMockDAG([node]), + createMockEdgeManager(), + nodeOrchestrator + ) + const result = await engine.run(node.id) + + const provenance = result.executionState?.finalOutputResolvedSecretTraceProvenance + expect(provenance?.complete).toBe(true) + expect(provenance?.entries).toEqual([{ name: 'TOKEN', encryptedValue: 'ciphertext' }]) + }) + + /** Deriving must not weaken the guarantee: a latched registry still exports incomplete. */ + it('keeps the final output envelope incomplete when the registry latched', async () => { + const node = createMockNode('loop-1', 'loop') + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'secret-value-1234', encryptedValue: 'ciphertext' }, + ]) + registry.markIncomplete('unspecified') + const context = createMockContext({ + decisions: { router: new Map(), condition: new Map() }, + resolvedSecretTraceRegistry: registry, + }) + const nodeOrchestrator = createMockNodeOrchestrator() + vi.mocked(nodeOrchestrator.executeNode).mockResolvedValue({ + nodeId: node.id, + output: { results: ['secret-value-1234'] }, + isFinalOutput: true, + }) + vi.mocked(nodeOrchestrator.handleNodeCompletion).mockImplementation( + (_ctx, nodeId, output) => { + context.blockStates.set(nodeId, { output, executed: true, executionTime: 1 }) + } + ) + + const engine = new ExecutionEngine( + context, + createMockDAG([node]), + createMockEdgeManager(), + nodeOrchestrator + ) + const result = await engine.run(node.id) + + expect(result.executionState?.finalOutputResolvedSecretTraceProvenance?.complete).toBe(false) + }) + it('should not fall back to starter blocks for terminal resume snapshots', async () => { const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 15cd56ea593..acfa7cfa42b 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -505,25 +505,31 @@ export class ExecutionEngine { this.context.finalOutputResolvedSecretTraceProvenance = state.resolvedSecretTraceProvenance return } + /** + * A block state without provenance is an absence of a shortcut, not a verdict. Several state + * writers legitimately store an output without one — a subflow sentinel aggregating iteration + * results is the common case, and a loop that ran no iterations has nothing to merge — so + * stamping an incomplete envelope here declared the run unvouchable whenever the last block + * was one of them. Every other consumer of a provenance-less block state falls back to the run + * registry; deriving does the same, against the value actually being described. + */ + this.deriveFinalOutputProvenance() + } - if (this.context.resolvedSecretTraceRegistry) { - this.context.finalOutputResolvedSecretTraceProvenance = { - version: 1, - complete: false, - entries: [], - } - } + /** + * Derives the final-output envelope from the run registry. Fails closed on its own terms: a + * latched registry exports an incomplete envelope, which is the genuinely unvouchable case. + */ + private deriveFinalOutputProvenance(): void { + const registry = this.context.resolvedSecretTraceRegistry + if (!registry) return + this.context.finalOutputResolvedSecretTraceProvenance = + registry.exportCommittedProvenanceForValue(this.finalOutput) } private ensureFinalOutputProvenance(): void { - if ( - Object.hasOwn(this.context, 'finalOutputResolvedSecretTraceProvenance') || - !this.context.resolvedSecretTraceRegistry - ) { - return - } - this.context.finalOutputResolvedSecretTraceProvenance = - this.context.resolvedSecretTraceRegistry.exportCommittedProvenanceForValue(this.finalOutput) + if (Object.hasOwn(this.context, 'finalOutputResolvedSecretTraceProvenance')) return + this.deriveFinalOutputProvenance() } private buildPausedResult(startTime: number): ExecutionResult {