mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(provenance): derive final-output provenance instead of declaring it unvouchable (#7173)
A run whose last block stored its output without provenance had its final-output envelope stamped incomplete. That is not what an absent block-state envelope means: several state writers legitimately omit one, and a subflow sentinel aggregating iteration results is the common case — a loop that ran no iterations has nothing to merge, so its aggregate carries none. The workflows hitting this in production end in a loop. The cost was not noise. An incomplete final-output envelope withholds finalOutput from the execution log, so the author of the workflow could not see their own run's output, on every view, forever — and each view re-derived the same verdict, which is the read-path re-fire now dominating the provenance stream. Derive from the run registry against the value being described, which is what the end-of-run path already did and what every other consumer of a provenance-less block state does. The two now share one derivation rather than disagreeing: a shortcut when the block state has an exact envelope, the registry otherwise. It fails closed on its own terms — a latched registry still exports incomplete — and the guard for that is pinned by its own test.
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user