fix(ai-builder): Stop the bypass-pin guard from replacing empty pins with a phantom item

The first smoke run proved the prompt+parse fix produced correct [] pins,
which the Phase-1.5 guard then converted back into [{json:{}}] — its
length===0 branch conflated "missing entry" (real-execution risk) with
"intentionally empty" (the engine honors [] as pinned-zero-items via a
presence check). Backfill only missing entries, and with [] instead of a
phantom item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jose 2026-07-25 16:37:15 +01:00
parent ca49200145
commit 602f86c19c
No known key found for this signature in database
2 changed files with 32 additions and 10 deletions

View File

@ -462,6 +462,29 @@ describe('EvalExecutionService', () => {
expect(workflowStaticDataService.saveStaticDataById).toHaveBeenCalledWith('wf-1', {});
});
it('preserves an intentional zero-item bypass pin instead of injecting a phantom item', async () => {
const bypassNode = {
id: 'node-3',
name: 'Only New Jobs',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1,
position: [400, 0],
parameters: {},
} as INode;
workflowFinderService.findWorkflowForUser.mockResolvedValue(
makeWorkflowEntity({ nodes: [makeStartNode(), bypassNode] }) as never,
);
identifyNodesForPinDataMock.mockReturnValue([bypassNode]);
generatePinDataMock.mockResolvedValue({ 'Only New Jobs': [] });
await service.executeWithLlmMock('wf-1', makeUser());
const runArg = workflowRunner.run.mock.calls[0][0] as unknown as {
pinData?: Record<string, unknown[]>;
};
expect(runArg.pinData?.['Only New Jobs']).toEqual([]);
});
it('returns a framework failure when bypass pin data generation fails', async () => {
const bypassNode = {
id: 'node-3',

View File

@ -303,19 +303,18 @@ export class EvalExecutionService {
const normalized = normalizePinData(result as unknown as IPinData);
// generatePinData swallows internal failures (LLM timeout, parse error)
// and returns {} or a partial map instead of throwing, so the catch
// fallback below never fires for those. An unpinned bypass node
// EXECUTES for real — for AI roots the vendor SDK then makes real
// network calls (observed in CI: un-mocked Anthropic request →
// "Authorization failed"). Guarantee every bypass node is pinned,
// even if only with an empty item.
// A MISSING bypass entry would let the node execute for real — for AI
// roots the vendor SDK then makes real network calls (observed in CI:
// un-mocked Anthropic request → "Authorization failed"). An EMPTY array
// is different: the execution engine honors it as "pinned, zero items"
// (presence check, not length), and zero-item scenario premises depend
// on it — never replace [] with a phantom item (TRUST-343).
for (const nodeName of bypassNodeNames) {
if (!normalized[nodeName] || normalized[nodeName].length === 0) {
if (!normalized[nodeName]) {
this.logger.warn(
`[EvalMock] Phase 1.5 produced no pin data for bypass node "${nodeName}" — pinning an empty item to prevent real execution`,
`[EvalMock] Phase 1.5 produced no pin data for bypass node "${nodeName}" — pinning empty to prevent real execution`,
);
normalized[nodeName] = [{ json: {} }];
normalized[nodeName] = [];
}
}