mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(ai-builder): Expose generated workflow IDs on LangSmith trace root metadata (#30262)
This commit is contained in:
parent
b445221c6a
commit
5059ce7e3d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user