feat(ai-builder): Expose generated workflow IDs on LangSmith trace root metadata (#30262)

This commit is contained in:
Albert Alises 2026-05-12 10:37:57 +02:00 committed by GitHub
parent b445221c6a
commit 5059ce7e3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 269 additions and 3 deletions

View File

@ -6,6 +6,8 @@ export type { CompactionInput } from './compaction';
export { createDomainAccessTracker } from './domain-access';
export type { DomainAccessTracker } from './domain-access';
export {
appendGeneratedWorkflowIdToRootMetadata,
appendRootRunMetadata,
createInstanceAiTraceContext,
createTraceReplayOnlyContext,
continueInstanceAiTraceContext,

View File

@ -1063,6 +1063,7 @@ export async function startBuildWorkflowAgentTask(
availableCredentials,
root,
currentRunId: context.runId,
tracingRoot: traceContext?.rootRun,
getWorkflowLoopState: async () =>
await context.workflowTaskService?.getWorkflowLoopState(workItemId),
onGuardFired: (event) => {

View File

@ -3,7 +3,7 @@ import { validateWorkflow } from '@n8n/workflow-sdk';
import { mock } from 'jest-mock-extended';
import type { INodeTypes } from 'n8n-workflow';
import type { InstanceAiContext } from '../../../types';
import type { InstanceAiContext, InstanceAiTraceRun } from '../../../types';
import {
classifySubmitFailure,
isTriggerNodeType,
@ -269,6 +269,69 @@ describe('createSubmitWorkflowTool — credential verification metadata', () =>
});
});
it('appends successful workflowId to the tracingRoot metadata', async () => {
mockedValidateWorkflow.mockReturnValue({ errors: [], warnings: [] } as never);
const context = makeContext({} as InstanceAiContext['permissions'], {
workflowService: {
createFromWorkflowJSON: jest.fn().mockResolvedValue({ id: 'wf-1' }),
} as unknown as InstanceAiContext['workflowService'],
});
const tracingRoot = {
id: 'root-1',
name: 'subagent:workflow-builder',
runType: 'chain',
projectName: 'instance-ai',
startTime: 0,
traceId: 'trace-1',
dottedOrder: '',
executionOrder: 0,
childExecutionOrder: 0,
} as InstanceAiTraceRun;
const tool = createSubmitWorkflowTool(
context,
makeBuildSuccessWorkspace({ name: 'Test', nodes: [], connections: {} }),
undefined,
undefined,
tracingRoot,
) as unknown as Executable;
await tool.execute({ filePath: 'src/workflow.ts', name: 'Test' });
expect(tracingRoot.metadata?.generated_workflow_ids).toEqual(['wf-1']);
});
it('does not write tracingRoot metadata when submission fails', async () => {
mockedValidateWorkflow.mockReturnValue({
errors: [{ code: 'INVALID_PARAM', message: 'bad', nodeName: 'X' }],
warnings: [],
} as never);
const context = makeContext();
const tracingRoot = {
id: 'root-2',
name: 'subagent:workflow-builder',
runType: 'chain',
projectName: 'instance-ai',
startTime: 0,
traceId: 'trace-2',
dottedOrder: '',
executionOrder: 0,
childExecutionOrder: 0,
} as InstanceAiTraceRun;
const tool = createSubmitWorkflowTool(
context,
makeBuildSuccessWorkspace(),
undefined,
undefined,
tracingRoot,
) as unknown as Executable;
await tool.execute({ filePath: 'src/workflow.ts', name: 'Test' });
expect(tracingRoot.metadata?.generated_workflow_ids).toBeUndefined();
});
it('reports Execute Workflow references from the submitted workflow', async () => {
mockedValidateWorkflow.mockReturnValue({ errors: [], warnings: [] } as never);
const attempts: SubmitWorkflowAttempt[] = [];

View File

@ -27,7 +27,7 @@ import {
type SubmitWorkflowInput,
type SubmitWorkflowOutput,
} from './submit-workflow.tool';
import type { InstanceAiContext } from '../../types';
import type { InstanceAiContext, InstanceAiTraceRun } from '../../types';
import {
MAX_PRE_SAVE_SUBMIT_FAILURES,
createRemediation,
@ -220,6 +220,7 @@ export function createIdentityEnforcedSubmitWorkflowTool(args: {
currentRunId?: string;
getWorkflowLoopState?: () => Promise<WorkflowLoopState | undefined>;
onGuardFired?: SubmitGuardOptions['onGuardFired'];
tracingRoot?: InstanceAiTraceRun;
}) {
const budgetTracker = createPreSaveBudgetTracker();
const underlying = createSubmitWorkflowTool(
@ -229,6 +230,7 @@ export function createIdentityEnforcedSubmitWorkflowTool(args: {
await args.onAttempt(budgetTracker.recordAttempt(attempt));
},
args.availableCredentials,
args.tracingRoot,
);
const underlyingExecute = underlying.execute as SubmitExecute | undefined;

View File

@ -17,7 +17,8 @@ import { z } from 'zod';
import { resolveCredentials, type CredentialEntry } from './resolve-credentials';
import { stripStaleCredentialsFromWorkflow } from './setup-workflow.service';
import { getReferencedWorkflowIds, isTriggerNodeType } from './workflow-json-utils';
import type { InstanceAiContext } from '../../types';
import { appendGeneratedWorkflowIdToRootMetadata } from '../../tracing/langsmith-tracing';
import type { InstanceAiContext, InstanceAiTraceRun } from '../../types';
import type { ValidationWarning } from '../../workflow-builder';
import { partitionWarnings } from '../../workflow-builder';
import { createRemediation } from '../../workflow-loop/remediation';
@ -267,6 +268,7 @@ export function createSubmitWorkflowTool(
workspace: Workspace,
onAttempt?: (attempt: SubmitWorkflowAttempt) => void | Promise<void>,
availableCredentials?: CredentialEntry[],
tracingRoot?: InstanceAiTraceRun,
) {
return createTool({
id: 'submit-workflow',
@ -494,6 +496,9 @@ export function createSubmitWorkflowTool(
referencedWorkflowIds: referencedWorkflowIds.length > 0 ? referencedWorkflowIds : undefined,
hasUnresolvedPlaceholders: hasPlaceholders || undefined,
});
if (tracingRoot) {
appendGeneratedWorkflowIdToRootMetadata(tracingRoot, savedId);
}
return {
success: true,
workflowId: savedId,

View File

@ -1,3 +1,5 @@
import type { InstanceAiTraceRun } from '../../types';
jest.mock('langsmith', () => {
let runCounter = 0;
const createdRunTrees: Array<{
@ -256,10 +258,13 @@ function isExecutableTool(value: unknown): value is ExecutableTool {
}
const {
appendRootRunMetadata,
appendGeneratedWorkflowIdToRootMetadata,
buildAgentTraceInputs,
createDetachedSubAgentTraceContext,
createInstanceAiTraceContext,
continueInstanceAiTraceContext,
mergeCurrentTraceMetadata,
mergeTraceRunInputs,
submitLangsmithUserFeedback,
withCurrentTraceSpan,
@ -927,3 +932,154 @@ describe('submitLangsmithUserFeedback', () => {
expect(getAuthHeaders).toHaveBeenCalled();
});
});
describe('appendGeneratedWorkflowIdToRootMetadata', () => {
function makeRoot(metadata?: Record<string, unknown>): InstanceAiTraceRun {
return {
id: 'root-1',
name: 'message_turn',
runType: 'chain',
projectName: 'instance-ai',
startTime: 0,
traceId: 'trace-1',
dottedOrder: '',
executionOrder: 0,
childExecutionOrder: 0,
...(metadata ? { metadata: { ...metadata } } : {}),
};
}
it('initialises generated_workflow_ids array on first append', () => {
const root = makeRoot();
appendGeneratedWorkflowIdToRootMetadata(root, 'wf-1');
expect(root.metadata?.generated_workflow_ids).toEqual(['wf-1']);
});
it('appends additional ids without losing existing entries', () => {
const root = makeRoot({ generated_workflow_ids: ['wf-1'] });
appendGeneratedWorkflowIdToRootMetadata(root, 'wf-2');
expect(root.metadata?.generated_workflow_ids).toEqual(['wf-1', 'wf-2']);
});
it('dedupes repeated ids', () => {
const root = makeRoot({ generated_workflow_ids: ['wf-1'] });
appendGeneratedWorkflowIdToRootMetadata(root, 'wf-1');
expect(root.metadata?.generated_workflow_ids).toEqual(['wf-1']);
});
it('ignores non-string entries when reading existing metadata', () => {
const root = makeRoot({ generated_workflow_ids: [42, null, 'wf-1'] as unknown[] });
appendGeneratedWorkflowIdToRootMetadata(root, 'wf-2');
expect(root.metadata?.generated_workflow_ids).toEqual(['wf-1', 'wf-2']);
});
it('preserves unrelated metadata', () => {
const root = makeRoot({ user_id: 'u-1', thread_id: 't-1' });
appendGeneratedWorkflowIdToRootMetadata(root, 'wf-1');
expect(root.metadata).toMatchObject({
user_id: 'u-1',
thread_id: 't-1',
generated_workflow_ids: ['wf-1'],
});
});
it('preserves live RunTree metadata mutations when appending root metadata', async () => {
const originalLangSmithApiKey = process.env.LANGSMITH_API_KEY;
const originalLangSmithTracing = process.env.LANGSMITH_TRACING;
const originalLangChainTracingV2 = process.env.LANGCHAIN_TRACING_V2;
langsmithMock.reset();
process.env.LANGSMITH_API_KEY = 'test-key';
delete process.env.LANGSMITH_TRACING;
delete process.env.LANGCHAIN_TRACING_V2;
try {
const tracing = await createDetachedSubAgentTraceContext({
threadId: 'thread-1',
conversationId: 'thread-1',
messageGroupId: 'group-1',
messageId: 'message-1',
runId: 'run-1',
userId: 'user-1',
agentId: 'agent-builder-1',
role: 'workflow-builder',
kind: 'builder',
taskId: 'build-1',
input: { task: 'Build a workflow' },
});
if (!tracing) {
throw new Error('Expected tracing context');
}
expect(tracing.rootRun.metadata?.agent_role).toBe('workflow-builder');
await tracing.withRunTree(tracing.actorRun, async () => {
await Promise.resolve();
// Overwrite an existing root metadata key on the live RunTree so the
// two diverge on the same key with different values. The subsequent
// append must preserve the live value instead of rolling it back to
// the stale root state.
mergeCurrentTraceMetadata({ agent_role: 'planner' });
appendGeneratedWorkflowIdToRootMetadata(tracing.rootRun, 'wf-1');
expect(tracing.rootRun.metadata?.generated_workflow_ids).toEqual(['wf-1']);
expect(tracing.rootRun.metadata?.agent_role).toBe('planner');
});
expect(tracing.rootRun.metadata?.generated_workflow_ids).toEqual(['wf-1']);
expect(tracing.rootRun.metadata?.agent_role).toBe('planner');
} finally {
if (originalLangSmithApiKey === undefined) {
delete process.env.LANGSMITH_API_KEY;
} else {
process.env.LANGSMITH_API_KEY = originalLangSmithApiKey;
}
if (originalLangSmithTracing === undefined) {
delete process.env.LANGSMITH_TRACING;
} else {
process.env.LANGSMITH_TRACING = originalLangSmithTracing;
}
if (originalLangChainTracingV2 === undefined) {
delete process.env.LANGCHAIN_TRACING_V2;
} else {
process.env.LANGCHAIN_TRACING_V2 = originalLangChainTracingV2;
}
}
});
});
describe('appendRootRunMetadata', () => {
it('merges new fields into root metadata', () => {
const root: InstanceAiTraceRun = {
id: 'root-1',
name: 'message_turn',
runType: 'chain',
projectName: 'instance-ai',
startTime: 0,
traceId: 'trace-1',
dottedOrder: '',
executionOrder: 0,
childExecutionOrder: 0,
metadata: { user_id: 'u-1' },
};
appendRootRunMetadata(root, { primary_workflow_id: 'wf-1' });
expect(root.metadata).toEqual({ user_id: 'u-1', primary_workflow_id: 'wf-1' });
});
it('overwrites existing values for the same key', () => {
const root: InstanceAiTraceRun = {
id: 'root-1',
name: 'message_turn',
runType: 'chain',
projectName: 'instance-ai',
startTime: 0,
traceId: 'trace-1',
dottedOrder: '',
executionOrder: 0,
childExecutionOrder: 0,
metadata: { final_status: 'pending' },
};
appendRootRunMetadata(root, { final_status: 'completed' });
expect(root.metadata?.final_status).toBe('completed');
});
});

View File

@ -522,6 +522,43 @@ export function mergeCurrentTraceMetadata(metadata: Record<string, unknown>): vo
}
}
export function appendRootRunMetadata(
root: InstanceAiTraceRun,
patch: Record<string, unknown>,
): void {
const currentRun = getTraceParentRun();
const baseMetadata =
currentRun?.id === root.id
? mergeRunTreeMetadata(root.metadata, currentRun.metadata)
: root.metadata;
const merged = mergeRunTreeMetadata(baseMetadata, patch);
if (merged) {
root.metadata = merged;
if (currentRun?.id === root.id) {
currentRun.metadata = merged;
}
}
}
export function appendGeneratedWorkflowIdToRootMetadata(
root: InstanceAiTraceRun,
workflowId: string,
): void {
const currentRun = getTraceParentRun();
const metadata =
currentRun?.id === root.id
? mergeRunTreeMetadata(root.metadata, currentRun.metadata)
: root.metadata;
const generatedWorkflowIds = metadata?.generated_workflow_ids;
const existing = Array.isArray(generatedWorkflowIds)
? generatedWorkflowIds.filter((value): value is string => typeof value === 'string')
: [];
if (existing.includes(workflowId)) {
return;
}
appendRootRunMetadata(root, { generated_workflow_ids: [...existing, workflowId] });
}
export function mergeTraceRunInputs(
run: InstanceAiTraceRun | undefined,
inputs: Record<string, unknown>,