Merge branch 'master' into source-control-commit-branch-selection

This commit is contained in:
Jannick Namyslo 2026-07-20 12:16:24 +02:00 committed by GitHub
commit 58ea2a2e77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
772 changed files with 31400 additions and 6064 deletions

View File

@ -7,9 +7,9 @@ description: 'Configures Node.js with pnpm, installs Aikido SafeChain for supply
inputs:
node-version:
description: 'Node.js version to use. Pinned to 24.16.0 by default for reproducible builds.'
description: 'Node.js version to use. Pinned to 24.18.0 by default for reproducible builds.'
required: false
default: '24.16.0'
default: '24.18.0'
enable-docker-cache:
description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).'
required: false
@ -251,8 +251,15 @@ runs:
shell: bash
run: echo "running=${TURBOGHA_PORT:+true}" >> "$GITHUB_OUTPUT"
# Skipped on Windows: the turbogha server binds IPv4 while `localhost`
# resolves to IPv6 first there, so turbo can never reach it (the build
# logs `failed to contact remote cache` for every artifact and falls back
# to a local cache). The remote cache is inert, but the action's post-run
# still fetches the unreachable server to save it and dies with a bare
# `fetch failed`, turning the whole job red. Skip it so the one Windows
# build job stays green; Linux/Blacksmith jobs keep remote caching.
- name: Configure Turborepo Cache
if: steps.turbo-server.outputs.running != 'true'
if: steps.turbo-server.outputs.running != 'true' && runner.os != 'Windows'
uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2
with:
server-port: 0

View File

@ -10,9 +10,7 @@
"n8n-node-dev",
"n8n-nodes-base",
"n8n-playwright",
"n8n-workflow",
"typescript",
"@typescript/*"
"n8n-workflow"
]
}
}

View File

@ -29,7 +29,7 @@ jobs:
# While we're on alpine 3.22 awaiting NODE-4184 (graphicsmagick → sharp
# migration that unblocks 3.23), Node 26 base builds can't succeed.
# Restore '26' to this matrix once 3.23 is back.
node_version: ['22', '24.16.0']
node_version: ['22', '24.18.0']
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

View File

@ -30,11 +30,11 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: [22.22.3, 24.16.0]
node-version: [22.22.3, 24.18.0]
with:
ref: ${{ github.sha }}
nodeVersion: ${{ matrix.node-version }}
collectCoverage: ${{ matrix.node-version == '24.16.0' }}
collectCoverage: ${{ matrix.node-version == '24.18.0' }}
secrets: inherit
lint:

View File

@ -58,7 +58,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24.16.0
node-version: 24.18.0
- name: Fetch read-all live ledger from BigQuery
# Read-all (no ?package= param): one curl returns every row across
@ -179,16 +179,16 @@ jobs:
# source_file re-score path). Without it emit-payload falls back to git,
# which yields null churn/fix_density on this shallow clone.
run: |
signals_arg=""
signals_arg=()
if [ -f .mutation-health/signals.json ]; then
signals_arg="--signals .mutation-health/signals.json"
signals_arg=(--signals .mutation-health/signals.json)
else
echo "::notice::signals.json not present — churn/fix_density will fall back to git (null on shallow clone)."
fi
node scripts/mutation-health/emit-payload.mjs \
--summary "$REPORTS_DIR/summary.json" \
--package "$PACKAGE_NAME" \
$signals_arg
"${signals_arg[@]}"
- name: POST result payload
env:

View File

@ -75,7 +75,7 @@ jobs:
steps:
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24.16.0
node-version: 24.18.0
# Remove after https://github.com/npm/cli/issues/8547 gets resolved
- run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc

View File

@ -66,7 +66,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '24.16.0'
node-version: '24.18.0'
# A version bump only edits one package.json, so no workspace install/build
# is needed. `pnpm ls -r --only-projects` reads the workspace config directly.

View File

@ -12,7 +12,7 @@ on:
description: Version of node to use.
required: false
type: string
default: 24.16.0
default: 24.18.0
env:
NODE_OPTIONS: --max-old-space-size=7168

View File

@ -12,7 +12,7 @@ on:
description: Version of node to use.
required: false
type: string
default: 24.16.0
default: 24.18.0
collectCoverage:
required: false
default: false

View File

@ -12,7 +12,7 @@ on:
description: Version of node to use.
required: false
type: string
default: 24.16.0
default: 24.18.0
env:
NODE_OPTIONS: --max-old-space-size=7168

View File

@ -1,7 +1,7 @@
# Pinned to a multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# The tag is the human-readable version; Docker resolves by the digest. Bump the tag and
# digest together when updating the base image.
FROM dhi.io/node:24.16.0-alpine3.24-dev@sha256:0fda302d7d6f2436b27edc9392bd6a4f8ae9ce86e9837c2b8676abdf26a4a7fb
FROM dhi.io/node:24.18.0-alpine3.24-dev@sha256:d508453231109fbe96a39045ecc794ac18f8b99ca218a0806833d65bb2750e03
# Install all dependencies in a single layer to minimize image size
RUN apk add --no-cache busybox-binsh && \

View File

@ -1,11 +1,11 @@
ARG NODE_VERSION=24.16.0
ARG NODE_VERSION=24.18.0
ARG N8N_VERSION=snapshot
# Builder stage exists because the runtime base image has no toolchain.
# Pinned to multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# Bump the digest together with the tag when updating the base image.
# Digest pins to node:24.16.0-alpine3.24 (Node 24.16.0, Alpine 3.24).
FROM node:24.16.0-alpine3.24@sha256:21f403ab171f2dc89bad4dd69d7721bfd15f084ccb46cdd225f31f2bc59b5c9a AS builder
# Digest pins to node:24.18.0-alpine3.24 (Node 24.18.0, Alpine 3.24).
FROM node:24.18.0-alpine3.24@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS builder
COPY ./compiled /usr/local/lib/node_modules/n8n
RUN apk add --no-cache python3 make g++ && \
cd /usr/local/lib/node_modules/n8n && \
@ -28,8 +28,8 @@ RUN apk add --no-cache python3 make g++ && \
# base change, so a base rebuild does not reach this image until the digest is
# manually re-pinned here. Bump the digest together with the tag whenever the base
# image is intentionally updated.
# Digest pins to n8nio/base:24.16.0 (Node 24.16.0, Alpine 3.24).
FROM n8nio/base:24.16.0@sha256:25e5671a1b3e11cd576bc9a4409ebf962baea993a4bc7414168dfd357e85a7e8
# Digest pins to n8nio/base:24.18.0 (Node 24.18.0, Alpine 3.24).
FROM n8nio/base:24.18.0@sha256:29d9c11f55f02442276512592b439ebb2a1d1043c1339ad5cea4e56862520d22
ARG N8N_VERSION
ARG N8N_RELEASE_TYPE=dev

View File

@ -201,6 +201,7 @@
"undici@5": "catalog:undici-v6",
"undici@6": "catalog:undici-v6",
"undici@7": "catalog:undici-v7",
"node-gyp>undici": "catalog:undici-v7",
"@babel/traverse": "^7.23.2"
},
"patchedDependencies": {

View File

@ -1,7 +1,7 @@
import { binaryJudgeResultSchema } from './schemas';
import { runWithOptionalLimiter, withTimeout } from '../../../harness/evaluation-helpers';
import { createEvaluatorChain, invokeEvaluatorChain } from '../../llm-judge/evaluators/base';
import type { BinaryCheck, BinaryCheckContext, SimpleWorkflow } from '../types';
import { binaryJudgeResultSchema } from './schemas';
const REASONING_FIRST_SUFFIX = `

View File

@ -36,11 +36,18 @@ export const BUILDER_NOT_CONFIGURED_CODE = 'BUILDER_NOT_CONFIGURED' as const;
export const BUILDER_CHECKPOINT_UNAVAILABLE_CODE = 'BUILDER_CHECKPOINT_UNAVAILABLE' as const;
/**
* The only two agent-builder tools that mutate the agent config. Mirrors
* `BUILDER_TOOLS.WRITE_CONFIG` / `PATCH_CONFIG` in
* Agent-builder tools whose success should set `configUpdated` on `build-agent`
* (refresh the agent artifact preview) and emit "Builder modified agent"
* telemetry. Includes config writers and publish lifecycle tools. Values must
* match `BUILDER_TOOLS` in
* `packages/cli/src/modules/agents/builder/builder-tool-names.ts`.
*/
export const CONFIG_MUTATION_TOOL_NAMES = ['write_config', 'patch_config'] as const;
export const CONFIG_MUTATION_TOOL_NAMES = [
'write_config',
'patch_config',
'publish_agent',
'unpublish_agent',
] as const;
// ---------------------------------------------------------------------------
// ask_questions

View File

@ -232,7 +232,7 @@ export const McpServerConfigSchema = z
'Unique server name, also used as the SDK tool-name prefix (e.g. github -> github_create_issue)',
),
description: z.string().max(512).optional().describe('Human-readable server description'),
url: z.string().min(1).describe('MCP server endpoint URL'),
url: z.string().describe('MCP server endpoint URL. Empty string means setup is incomplete'),
transport: z
.enum(['sse', 'streamableHttp'])
.default('streamableHttp')
@ -258,7 +258,7 @@ export const McpServerConfigSchema = z
})
.optional()
.describe(
'Server-generated metadata. Do not set this manually, only copy from search_mcp_servers result if present',
'Server-generated metadata. Do not set this manually; only copy it from an MCP discovery result when present',
),
toolFilter: z
.discriminatedUnion('mode', [

View File

@ -86,6 +86,35 @@ describe('ExportPackageRequestDto', () => {
});
});
describe('includeVariableValues', () => {
it('defaults to true when omitted', () => {
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['wf-1'] });
expect(result.success).toBe(true);
if (result.success) expect(result.data.includeVariableValues).toBe(true);
});
it.each([true, false])('accepts explicit %s', (includeVariableValues) => {
const result = ExportPackageRequestDto.safeParse({
workflowIds: ['wf-1'],
includeVariableValues,
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.includeVariableValues).toBe(includeVariableValues);
});
it.each([
{ name: 'string value', includeVariableValues: 'false' },
{ name: 'numeric value', includeVariableValues: 0 },
{ name: 'null value', includeVariableValues: null },
])('rejects $name', ({ includeVariableValues }) => {
const result = ExportPackageRequestDto.safeParse({
workflowIds: ['wf-1'],
includeVariableValues,
});
expect(result.success).toBe(false);
});
});
describe('missingWorkflowDependencyPolicy', () => {
it.each(['fail', 'reference-only', 'include-in-package'])(
'accepts %s',

View File

@ -6,6 +6,7 @@ export class ExportPackageRequestDto extends Z.class({
workflowIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
folderIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
projectIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
includeVariableValues: z.boolean().default(true),
missingWorkflowDependencyPolicy: z
.enum(['fail', 'reference-only', 'include-in-package'])
.optional()

View File

@ -1,3 +1,5 @@
import { GROUP_DESCRIPTION_MAX_LENGTH } from 'n8n-workflow';
import { CreateWorkflowDto } from '../create-workflow.dto';
describe('CreateWorkflowDto', () => {
@ -67,6 +69,22 @@ describe('CreateWorkflowDto', () => {
nodeGroups: [{ id: 'group1', name: 'Data Fetching', nodeIds: ['node1', 'node2'] }],
},
},
{
name: 'with a group description at the length cap',
request: {
name: 'Grouped Workflow',
nodes: [],
connections: {},
nodeGroups: [
{
id: 'group1',
name: 'Data Fetching',
nodeIds: ['node1'],
description: 'a'.repeat(GROUP_DESCRIPTION_MAX_LENGTH),
},
],
},
},
{
name: 'with empty nodeGroups array',
request: {
@ -370,6 +388,23 @@ describe('CreateWorkflowDto', () => {
},
expectedErrorPath: ['nodeGroups', 0, 'nodeIds', 0],
},
{
name: 'nodeGroups with description over the length cap',
request: {
name: 'Test',
nodes: [],
connections: {},
nodeGroups: [
{
id: 'g1',
name: 'Group',
nodeIds: [],
description: 'a'.repeat(GROUP_DESCRIPTION_MAX_LENGTH + 1),
},
],
},
expectedErrorPath: ['nodeGroups', 0, 'description'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = CreateWorkflowDto.safeParse(request);
expect(result.success).toBe(false);

View File

@ -1,3 +1,4 @@
import { GROUP_DESCRIPTION_MAX_LENGTH } from 'n8n-workflow';
import type { IPinData, IConnections, IDataObject, INode, IWorkflowSettings } from 'n8n-workflow';
import { z } from 'zod';
@ -98,6 +99,12 @@ const workflowGroupSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
nodeIds: z.array(z.string().min(1)),
description: z
.string()
.max(GROUP_DESCRIPTION_MAX_LENGTH, {
message: `Group description must be ${GROUP_DESCRIPTION_MAX_LENGTH} characters or less`,
})
.optional(),
});
export const workflowNodeGroupsSchema = z.array(workflowGroupSchema);

View File

@ -257,6 +257,13 @@ export interface FrontendSettings {
easyAIWorkflowOnboarded: boolean;
evaluation: {
quota: number;
/**
* Operator override (`N8N_EVAL_COLLECTIONS_ENABLED`) that force-enables the
* eval-collections surface. Surfaced here so the frontend gate works even
* when the in-browser PostHog client is disabled (telemetry off), where the
* `084_eval_collections` flag would otherwise never resolve.
*/
collectionsEnabled: boolean;
};
/** Backend modules that were initialized during startup. */
@ -359,9 +366,10 @@ export type FrontendModuleSettings = {
*/
modules: string[];
/**
* Whether the agent knowledge base is enabled. Requires
* `N8N_AGENTS_AI_SANDBOX_ENABLED=true` and
* `N8N_AGENTS_AI_SANDBOX_PROVIDER=daytona` on the backend.
* Whether the agent knowledge base is enabled. True when the backend's
* Daytona sandbox env vars (`N8N_AGENTS_AI_SANDBOX_ENABLED=true` +
* `N8N_AGENTS_AI_SANDBOX_PROVIDER=daytona`) are set, OR the AI Assistant
* proxy is available.
*/
knowledgeBaseEnabled: boolean;
};

View File

@ -108,6 +108,7 @@ export type {
} from './push/chat-hub';
export type { Collaborator } from './push/collaboration';
export type { WorkflowPublicationStatusMessage } from './push/workflow';
export type { HeartbeatMessage } from './push/heartbeat';
export { createHeartbeatMessage, heartbeatMessageSchema } from './push/heartbeat';
export type { SendWorkerStatusMessage } from './push/worker';
@ -339,6 +340,7 @@ export {
INSTANCE_AI_MEMORY_TASK_WAIT_TIMEOUT_MS,
AI_GATEWAY_MANAGED_TAG,
InstanceAiEvalRestoreThreadRequest,
InstanceAiEvalSeedDataTableRowsRequest,
instanceAiGatewayKeySchema,
InstanceAiGatewayEventsQuery,
InstanceAiEventsQuery,
@ -355,6 +357,7 @@ export {
InstanceAiGatewayCapabilitiesDto,
InstanceAiGatewayCreateCredentialDto,
InstanceAiFilesystemResponseDto,
instanceAiEvalSeedDataTableSchema,
applyBranchReadOnlyOverrides,
normalizeInstanceAiThreadSource,
} from './schemas/instance-ai.schema';
@ -515,6 +518,9 @@ export {
export {
EVAL_COLLECTIONS_FLAG,
RESERVED_METRIC_KEYS,
ONE_TO_FIVE_METRIC_KEYS,
normalizeMetricScore,
evalCollectionVersionEntrySchema,
createEvaluationCollectionSchema,
CreateEvaluationCollectionDto,

View File

@ -78,3 +78,10 @@ export type WorkflowPushMessage =
| WorkflowAutoDeactivated
| WorkflowUpdated
| WorkflowSettingsUpdated;
/** Push messages that report the outcome of a workflow publication. */
export type WorkflowPublicationStatusMessage =
| WorkflowActivated
| WorkflowFailedToActivate
| WorkflowPartiallyActivated
| WorkflowDeactivated;

View File

@ -100,8 +100,14 @@ function makeAgentSpawned(
parentId: string,
role = 'sub-agent',
tools = ['tool-a'],
targetResource?: Extract<InstanceAiEvent, { type: 'agent-spawned' }>['payload']['targetResource'],
): Extract<InstanceAiEvent, { type: 'agent-spawned' }> {
return { type: 'agent-spawned', runId, agentId, payload: { parentId, role, tools } };
return {
type: 'agent-spawned',
runId,
agentId,
payload: { parentId, role, tools, targetResource },
};
}
function makeAgentCompleted(
@ -1031,6 +1037,91 @@ describe('agent-run-reducer', () => {
expect(state.agentsById['root'].children).toHaveLength(1);
expect(state.agentsById['sub-1'].textContent).toBe('kept');
});
it('republished agent-spawned for the same target upserts targetResource without a second node', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(
state,
makeAgentSpawned('run-1', 'sub-1', 'root', 'agent-builder', [], {
type: 'agent',
id: 'agent-1',
projectId: 'proj-1',
}),
);
reduceEvent(
state,
makeAgentSpawned('run-1', 'sub-1', 'root', 'agent-builder', [], {
type: 'agent',
id: 'agent-1',
projectId: 'proj-1',
name: 'Support Bot',
}),
);
expect(state.agentsById['root'].children).toHaveLength(1);
expect(state.agentsById['sub-1'].targetResource).toEqual({
type: 'agent',
id: 'agent-1',
projectId: 'proj-1',
name: 'Support Bot',
});
});
it('an unnamed replayed agent-spawned does not erase a known targetResource name', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(
state,
makeAgentSpawned('run-1', 'sub-1', 'root', 'agent-builder', [], {
type: 'agent',
id: 'agent-1',
projectId: 'proj-1',
name: 'Support Bot',
}),
);
reduceEvent(
state,
makeAgentSpawned('run-1', 'sub-1', 'root', 'agent-builder', [], {
type: 'agent',
id: 'agent-1',
projectId: 'proj-1',
}),
);
expect(state.agentsById['sub-1'].targetResource?.name).toBe('Support Bot');
});
it('a replayed agent-spawned for a different target resource leaves the node untouched', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(
state,
makeAgentSpawned('run-1', 'sub-1', 'root', 'agent-builder', [], {
type: 'agent',
id: 'agent-1',
projectId: 'proj-1',
name: 'Support Bot',
}),
);
reduceEvent(
state,
makeAgentSpawned('run-1', 'sub-1', 'root', 'agent-builder', [], {
type: 'agent',
id: 'agent-2',
projectId: 'proj-1',
name: 'Other Agent',
}),
);
expect(state.agentsById['root'].children).toHaveLength(1);
expect(state.agentsById['sub-1'].targetResource).toEqual({
type: 'agent',
id: 'agent-1',
projectId: 'proj-1',
name: 'Support Bot',
});
});
});
describe('stateFromAgentTree', () => {

View File

@ -0,0 +1,38 @@
import { normalizeMetricScore, RESERVED_METRIC_KEYS } from '../eval-collections.schema';
describe('normalizeMetricScore', () => {
it('excludes reserved operational metrics', () => {
for (const key of RESERVED_METRIC_KEYS) {
// Even a value that happens to land in [0, 1] is not a score.
expect(normalizeMetricScore(key, 0.5)).toBeNull();
expect(normalizeMetricScore(key, 1719)).toBeNull();
}
});
it('normalizes 15 AI-judge metrics onto [0, 1]', () => {
expect(normalizeMetricScore('correctness', 5)).toBe(1);
expect(normalizeMetricScore('correctness', 4)).toBe(0.8);
expect(normalizeMetricScore('correctness', 1)).toBe(0.2);
expect(normalizeMetricScore('helpfulness', 2.5)).toBe(0.5);
});
it('drops AI-judge values that fall outside the 15 range once scaled', () => {
// 6 / 5 = 1.2 → out of [0, 1]
expect(normalizeMetricScore('correctness', 6)).toBeNull();
expect(normalizeMetricScore('helpfulness', -1)).toBeNull();
});
it('passes through other metrics only when already in [0, 1]', () => {
expect(normalizeMetricScore('accuracy', 0.9)).toBe(0.9);
expect(normalizeMetricScore('accuracy', 0)).toBe(0);
expect(normalizeMetricScore('accuracy', 1)).toBe(1);
// Unknown-scale values outside [0, 1] can't be scaled → excluded.
expect(normalizeMetricScore('accuracy', 1.5)).toBeNull();
expect(normalizeMetricScore('accuracy', -0.2)).toBeNull();
});
it('returns null for NaN', () => {
expect(normalizeMetricScore('accuracy', Number.NaN)).toBeNull();
expect(normalizeMetricScore('correctness', Number.NaN)).toBeNull();
});
});

View File

@ -84,6 +84,7 @@ describe('insightsByWorkflowSchema', () => {
runTime: 300,
averageRunTime: 30.5,
timeSaved: 50,
hasReadAccess: true,
},
],
};
@ -103,6 +104,7 @@ describe('insightsByWorkflowSchema', () => {
...validInsightsByWorkflow.data[0],
workflowId: null,
projectId: null,
hasReadAccess: true,
},
],
},
@ -122,6 +124,7 @@ describe('insightsByWorkflowSchema', () => {
runTime: 300,
averageRunTime: 30,
timeSaved: 50,
hasReadAccess: true,
},
],
},
@ -140,6 +143,7 @@ describe('insightsByWorkflowSchema', () => {
failureRate: 10,
runTime: 300,
averageRunTime: 30,
hasReadAccess: true,
},
],
},
@ -160,6 +164,7 @@ describe('insightsByWorkflowSchema', () => {
averageRunTime: 30,
timeSaved: 50,
extraKey: 'value',
hasReadAccess: true,
},
],
},

View File

@ -406,9 +406,22 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
case 'agent-spawned': {
if (!isSafeObjectKey(event.agentId) || !isSafeObjectKey(event.payload.parentId)) break;
// Idempotency guard: a replayed agent-spawned for an existing agent
// must not create a second node for the same id.
if (state.agentsById[event.agentId]) break;
// A repeated agent-spawned for a known agent is an upsert of display
// metadata, never a second node or timeline entry: the builder
// republishes the event when the target agent's name changes, so
// refresh targetResource — but never erase a known name with an
// unnamed replay.
const existingNode = state.agentsById[event.agentId];
if (existingNode) {
const incoming = event.payload.targetResource;
if (incoming && incoming.id === existingNode.targetResource?.id) {
existingNode.targetResource = {
...incoming,
name: incoming.name ?? existingNode.targetResource?.name,
};
}
break;
}
const parentAgent = ensureAgent(state, event.payload.parentId);
if (parentAgent) {
const child: InstanceAiAgentNode = {

View File

@ -14,6 +14,45 @@ export type EvalCollectionRunStatus = 'new' | 'running' | 'completed' | 'error'
// available after `083_canvas_nodes_grouping`).
export const EVAL_COLLECTIONS_FLAG = '084_eval_collections';
// Metric keys every run emits automatically (token counts + execution time).
// They're absolute operational values, not quality scores, so the "avg score"
// derivation and the score-shaped charts exclude them and count only
// user-defined metrics. Single-sourced so FE and BE agree on what a "score"
// is (the FE builds its `PREDEFINED_METRIC_KEYS` set from this).
export const RESERVED_METRIC_KEYS = [
'promptTokens',
'completionTokens',
'totalTokens',
'executionTime',
] as const;
// User-defined metrics whose LLM-as-judge handlers return a 15 rating (rather
// than a 01 fraction). Every other user metric is assumed already normalized
// to [0, 1]. Single-sourced so FE and BE agree; the FE's `getMetricCategory`
// derives its `aiBased` category from this list.
export const ONE_TO_FIVE_METRIC_KEYS = ['correctness', 'helpfulness'] as const;
/**
* Normalize a raw metric value to a [0, 1] "score", or return null when the
* metric isn't a score we can chart/average:
* - reserved operational metrics (token counts, execution time) null
* - 15 AI-judge metrics value / 5
* - any other metric passed through only if already in [0, 1]; an
* unknown-scale value outside that range can't be meaningfully scaled, so
* it's excluded rather than rendered as a bogus percentage.
*
* Shared by the FE compare surfaces and the BE avg-score/insights derivation
* so a "score" means the same thing everywhere.
*/
export function normalizeMetricScore(key: string, value: number): number | null {
if ((RESERVED_METRIC_KEYS as readonly string[]).includes(key)) return null;
if (Number.isNaN(value)) return null;
const normalized = (ONE_TO_FIVE_METRIC_KEYS as readonly string[]).includes(key)
? value / 5
: value;
return normalized >= 0 && normalized <= 1 ? normalized : null;
}
// Per-version entry on a create-collection request. Either reference an
// existing test run (`existingTestRunId`) to reuse it, or omit it and the
// service will schedule a fresh run pinned to `workflowVersionId`. A null

View File

@ -51,6 +51,8 @@ export const insightsByWorkflowDataSchemas = {
// Workflow id will be null if the workflow has been deleted
workflowId: z.string().nullable(),
workflowName: z.string(),
// Whether the requesting user can read this workflow
hasReadAccess: z.boolean(),
// Project id will be null if the project has been deleted
projectId: z.string().nullable(),
projectName: z.string(),

View File

@ -920,6 +920,12 @@ export const instanceAiAgentPreviewHandoffContextSchema = z.object({
agentId: z.string().min(1).max(128),
threadId: z.string().min(1).max(128),
executionId: z.string().min(1).max(64).optional(),
/** Display-only — the target agent's name, surfaced in the context chip. */
agentName: z.string().max(128).optional(),
/** Display-only — the target agent's personalisation icon, surfaced in the context chip. */
agentIcon: z.string().max(64).optional(),
/** Display-only — the preview session's title, surfaced in the context chip. */
sessionTitle: z.string().max(200).optional(),
});
export type InstanceAiAgentPreviewHandoffContext = z.infer<
typeof instanceAiAgentPreviewHandoffContextSchema
@ -1625,10 +1631,17 @@ export type InstanceAiEvalSeedWorkflow = z.infer<typeof instanceAiEvalSeedWorkfl
/** A data table a seed references. Recreated on restore (its id is server-
* generated, so the seed workflows' references are rewritten to the new id).
* Schema only no rows (the table just needs to exist; rows are the trace's
* highest-PII payload and are never sent here). */
const instanceAiEvalSeedDataTableSchema = z.object({
id: z.string().min(1).max(64),
* Real conversation seeds send `columns` only rows are the trace's highest-PII
* payload and are never sent for those. Authored eval scenarios (TRUST-311) may
* additionally send `rows`, so a string id like `row_001` can be seeded into an
* explicitly `string`-typed column instead of being rejected by free-text
* `dataSetup` landing it in a `number` column. */
export const instanceAiEvalSeedDataTableSchema = z.object({
// ≥8 chars: restore remaps this id by whole-document string replace, and a
// short id would risk corrupting unrelated substrings — so the restore path
// refuses shorter ids. Enforcing it here fails a bad fixture at load time
// instead of after a workflow has already been built.
id: z.string().min(8).max(64),
name: z.string().min(1).max(128),
columns: z
.array(
@ -1638,16 +1651,46 @@ const instanceAiEvalSeedDataTableSchema = z.object({
}),
)
.max(50),
/** Optional seed rows, keyed by column name. Cell values arrive as JSON
* scalars (dates as ISO strings); the data-table service validates each cell
* against its declared column type on insert. */
rows: z
.array(z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])))
.max(1000)
.optional(),
});
export type InstanceAiEvalSeedDataTable = z.infer<typeof instanceAiEvalSeedDataTableSchema>;
export class InstanceAiEvalRestoreThreadRequest extends Z.class({
threadId: z.string().uuid(),
/** Native agent message log (ISO `createdAt`), stored verbatim. */
messages: z.array(z.record(z.unknown())).min(1).max(1000),
/** Native agent message log (ISO `createdAt`), stored verbatim. May be empty
* when the request only seeds data tables (TRUST-311 scenario seeding). */
messages: z.array(z.record(z.unknown())).max(1000),
/** Data tables the workflows reference; recreated first so ids can be rewritten. */
dataTables: z.array(instanceAiEvalSeedDataTableSchema).max(20).optional(),
/** Workflows the history references; recreated (node credentials stripped). */
workflows: z.array(instanceAiEvalSeedWorkflowSchema).max(50).optional(),
/** Append a unique suffix to each seed data table's name (default true safe
* for id-remapped seed workflows). False keeps the EXACT declared name so a
* freshly-built workflow's by-name references resolve. */
uniquifyNames: z.boolean().optional(),
}) {}
/**
* Reset an existing data table's rows to exactly `rows` (clear-then-insert).
* Unlike restore-thread (which CREATES tables), this targets a table that
* already exists by id used for the per-scenario row seeding of a case whose
* tables were created empty before the build turn (TRUST-311 follow-up). The
* table is scoped to the thread's project server-side.
*/
export class InstanceAiEvalSeedDataTableRowsRequest extends Z.class({
threadId: z.string().uuid(),
/** Id of the (already existing) data table whose rows are reset. */
tableId: z.string().min(8).max(64),
/** The exact row set the table should hold after seeding (may be empty to
* clear it). Cell values are validated against each column's type on insert. */
rows: z
.array(z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])))
.max(1000),
}) {}

View File

@ -83,7 +83,7 @@
"@types/qs": "catalog:",
"@vitest/coverage-v8": "catalog:",
"nock": "catalog:",
"typescript": "catalog:",
"typescript": "catalog:typescript",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
},

View File

@ -1,5 +1,5 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",

View File

@ -1,5 +1,5 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"types": ["node", "vitest/globals"],
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",

View File

@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
FROM node:24.16.0 AS base
FROM node:24.18.0 AS base
# Install required dependencies
RUN apt-get update && apt-get install -y gnupg2 curl

View File

@ -16,6 +16,7 @@ n8n-cli package export -w abc -w def -o team.n8np
n8n-cli package export --folder-id=xyz -o folders.n8np
n8n-cli package export --project-id=abc -o project.n8np
n8n-cli package export -p abc -p def -o projects.n8np
n8n-cli package export -w abc --include-variable-values=false -o export.n8np
```
| Flag | Description |
@ -24,6 +25,7 @@ n8n-cli package export -p abc -p def -o projects.n8np
| `-f, --folder-id` | Folder ID to include with its nested folders. Repeat the flag to export several. |
| `-p, --project-id` | Project ID to include. Repeat the flag to export several. |
| `-o, --output` | File to write the package to. Defaults to `export.n8np`. |
| `--include-variable-values` | `true` (default) or `false`. Whether values of variables referenced by the exported workflows are bundled into the package. When `false`, variables still travel as name/type files (and in the package requirements), just without their values. |
| `--missing-workflow-dependency-policy` | Policy for missing static sub-workflow dependencies: `fail`, `reference-only`, or `include-in-package`. Currently only `fail` is supported. |
Provide at least one `--workflow-id`, `--folder-id`, or `--project-id`. Requires

View File

@ -103,6 +103,33 @@ describe('N8nClient packages', () => {
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe(JSON.stringify({ folderIds: ['f1'] }));
});
it('includes includeVariableValues=false in the body when provided', async () => {
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
await client.exportPackage({ workflowIds: ['a'], includeVariableValues: false });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'], includeVariableValues: false }));
});
it('includes includeVariableValues=true in the body when provided', async () => {
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
await client.exportPackage({ workflowIds: ['a'], includeVariableValues: true });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'], includeVariableValues: true }));
});
it('omits includeVariableValues from the body when not provided', async () => {
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
await client.exportPackage({ workflowIds: ['a'] });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'] }));
});
});
describe('importPackage', () => {

View File

@ -9,30 +9,25 @@ vi.mock('node:fs');
const mockedWriteFileSync = vi.mocked(fs.writeFileSync);
interface ExportFlags {
workflowId?: string[];
folderId?: string[];
projectId?: string[];
output: string;
includeVariableValues?: string;
missingWorkflowDependencyPolicy?: string;
}
/** The command methods we stub to isolate behaviour from oclif/networking. */
interface ExportInternals {
parse: () => Promise<{
flags: {
workflowId?: string[];
folderId?: string[];
projectId?: string[];
output: string;
missingWorkflowDependencyPolicy: string;
};
}>;
parse: () => Promise<{ flags: ExportFlags }>;
getClient: () => N8nClient;
succeed: () => void;
error: (message: string) => never;
}
function stubCommand(
flags: {
workflowId?: string[];
folderId?: string[];
projectId?: string[];
output: string;
missingWorkflowDependencyPolicy?: string;
},
flags: ExportFlags,
exportPackage = vi.fn().mockResolvedValue(Buffer.from([1, 2, 3])),
) {
const command = new PackageExport([], {} as Config);
@ -65,6 +60,7 @@ describe('package export command', () => {
expect(exportPackage).toHaveBeenCalledWith({
workflowIds: ['wf-1', 'wf-2'],
folderIds: [],
includeVariableValues: true,
missingWorkflowDependencyPolicy: 'fail',
});
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/team.n8np', Buffer.from([1, 2, 3]));
@ -81,6 +77,7 @@ describe('package export command', () => {
expect(exportPackage).toHaveBeenCalledWith({
workflowIds: [],
folderIds: ['fld-1'],
includeVariableValues: true,
missingWorkflowDependencyPolicy: 'fail',
});
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/folders.n8np', Buffer.from([1, 2, 3]));
@ -98,6 +95,7 @@ describe('package export command', () => {
expect(exportPackage).toHaveBeenCalledWith({
workflowIds: ['wf-1'],
folderIds: ['fld-1'],
includeVariableValues: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
@ -115,6 +113,7 @@ describe('package export command', () => {
expect(exportPackage).toHaveBeenCalledWith({
workflowIds: ['wf-1'],
folderIds: ['fld-1'],
includeVariableValues: true,
missingWorkflowDependencyPolicy: 'reference-only',
});
});
@ -129,6 +128,7 @@ describe('package export command', () => {
expect(exportPackage).toHaveBeenCalledWith({
projectIds: ['proj-1', 'proj-2'],
includeVariableValues: true,
missingWorkflowDependencyPolicy: 'fail',
});
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/projects.n8np', Buffer.from([1, 2, 3]));
@ -145,10 +145,61 @@ describe('package export command', () => {
expect(exportPackage).toHaveBeenCalledWith({
projectIds: ['proj-1'],
includeVariableValues: true,
missingWorkflowDependencyPolicy: 'include-in-package',
});
});
it('forwards includeVariableValues=false when the flag is set', async () => {
const { command, exportPackage } = stubCommand({
workflowId: ['wf-1'],
output: '/tmp/export.n8np',
includeVariableValues: 'false',
});
await command.run();
expect(exportPackage).toHaveBeenCalledWith({
workflowIds: ['wf-1'],
folderIds: [],
includeVariableValues: false,
missingWorkflowDependencyPolicy: 'fail',
});
});
it('forwards includeVariableValues=false for a project export', async () => {
const { command, exportPackage } = stubCommand({
projectId: ['proj-1'],
output: '/tmp/project.n8np',
includeVariableValues: 'false',
});
await command.run();
expect(exportPackage).toHaveBeenCalledWith({
projectIds: ['proj-1'],
includeVariableValues: false,
missingWorkflowDependencyPolicy: 'fail',
});
});
it('treats an explicit --include-variable-values=true like the default', async () => {
const { command, exportPackage } = stubCommand({
workflowId: ['wf-1'],
output: '/tmp/export.n8np',
includeVariableValues: 'true',
});
await command.run();
expect(exportPackage).toHaveBeenCalledWith({
workflowIds: ['wf-1'],
folderIds: [],
includeVariableValues: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
it('rejects providing both workflow and project IDs', async () => {
const { command, exportPackage } = stubCommand({
workflowId: ['wf-1'],

View File

@ -27,6 +27,7 @@ export interface ExportPackageFields {
workflowIds?: string[];
folderIds?: string[];
projectIds?: string[];
includeVariableValues?: boolean;
missingWorkflowDependencyPolicy?: string;
}
@ -439,11 +440,14 @@ export class N8nClient {
workflowIds?: string[];
folderIds?: string[];
projectIds?: string[];
includeVariableValues?: boolean;
missingWorkflowDependencyPolicy?: string;
} = {};
if (fields.workflowIds?.length) body.workflowIds = fields.workflowIds;
if (fields.folderIds?.length) body.folderIds = fields.folderIds;
if (fields.projectIds?.length) body.projectIds = fields.projectIds;
// `undefined` is dropped by JSON serialization, so the API's default applies.
body.includeVariableValues = fields.includeVariableValues;
if (fields.missingWorkflowDependencyPolicy)
body.missingWorkflowDependencyPolicy = fields.missingWorkflowDependencyPolicy;

View File

@ -13,6 +13,7 @@ export default class PackageExport extends BaseCommand {
'<%= config.bin %> package export --folder-id=xyz -o folders.n8np',
'<%= config.bin %> package export --project-id=abc -o project.n8np',
'<%= config.bin %> package export -p abc -p def -o projects.n8np',
'<%= config.bin %> package export -w abc --include-variable-values=false -o export.n8np',
];
static override flags = {
@ -40,6 +41,14 @@ export default class PackageExport extends BaseCommand {
description: 'File to write the package to',
default: 'export.n8np',
}),
// String enum instead of Flags.boolean so `--include-variable-values=false` works (oclif booleans only support --no-*).
includeVariableValues: Flags.string({
description:
'Whether values of referenced variables are bundled into the package (the variables themselves always travel, value-less when false)',
options: ['true', 'false'],
default: 'true',
aliases: ['include-variable-values'],
}),
missingWorkflowDependencyPolicy: Flags.string({
options: ['fail', 'reference-only', 'include-in-package'],
default: 'fail',
@ -54,6 +63,7 @@ export default class PackageExport extends BaseCommand {
const workflowIds = flags.workflowId ?? [];
const folderIds = flags.folderId ?? [];
const projectIds = flags.projectId ?? [];
const includeVariableValues = flags.includeVariableValues !== 'false';
const missingWorkflowDependencyPolicy = flags.missingWorkflowDependencyPolicy;
// A package is either loose workflows/folders or whole projects, not both.
@ -70,8 +80,8 @@ export default class PackageExport extends BaseCommand {
try {
archive = await client.exportPackage(
projectIds.length > 0
? { projectIds, missingWorkflowDependencyPolicy }
: { workflowIds, folderIds, missingWorkflowDependencyPolicy },
? { projectIds, includeVariableValues, missingWorkflowDependencyPolicy }
: { workflowIds, folderIds, includeVariableValues, missingWorkflowDependencyPolicy },
);
} catch (error) {
throw toPackagesError(error);

View File

@ -23,6 +23,7 @@
"dist/**/*"
],
"dependencies": {
"@n8n/backend-network": "workspace:*",
"@n8n/utils": "workspace:*",
"axios": "catalog:"
},
@ -30,6 +31,7 @@
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"https-proxy-agent": "catalog:",
"typescript": "catalog:typescript",
"vite": "catalog:",
"vitest": "catalog:",

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createHttpsProxyAgent } from '@n8n/backend-network/proxy';
import axios from 'axios';
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import { Agent } from 'https';
import * as qs from 'querystring';
import type { ClientOAuth2TokenData } from './client-oauth2-token';
@ -57,8 +57,6 @@ export class ResponseError extends Error {
}
}
const sslIgnoringAgent = new Agent({ rejectUnauthorized: false });
/**
* Construct an object that can handle the multiple OAuth 2.0 flows.
*/
@ -112,7 +110,12 @@ export class ClientOAuth2 {
};
if (options.ignoreSSLIssues) {
requestConfig.httpsAgent = sslIgnoringAgent;
// Build a proxy-aware agent that relaxes TLS verification, so ignoring SSL
// issues still routes through the env proxy (HTTPS_PROXY / NO_PROXY) instead
// of connecting directly and bypassing the proxy.
requestConfig.httpsAgent = createHttpsProxyAgent(url, undefined, {
rejectUnauthorized: false,
});
}
const response = await axios.request(requestConfig);

View File

@ -1,4 +1,6 @@
import axios from 'axios';
import { Agent as HttpsAgent } from 'https';
import { HttpsProxyAgent } from 'https-proxy-agent';
import nock from 'nock';
import { ClientOAuth2, ResponseError } from '@/client-oauth2';
@ -230,6 +232,145 @@ describe('ClientOAuth2', () => {
expect(result.message).toEqual('HTTP status 302');
expect(result.body).toEqual('Redirected');
});
describe('ignoreSSLIssues', () => {
const PROXY_ENV_VARS = ['HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy'] as const;
let savedProxyEnv: Record<string, string | undefined>;
beforeEach(() => {
savedProxyEnv = {};
for (const key of PROXY_ENV_VARS) {
savedProxyEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of PROXY_ENV_VARS) {
if (savedProxyEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedProxyEnv[key];
}
}
vi.restoreAllMocks();
});
const makeIgnoreSSLCall = async () =>
await client.accessTokenRequest({
url: config.accessTokenUri,
method: 'POST',
headers: {
Authorization: authHeader,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
refresh_token: 'test',
grant_type: 'refresh_token',
},
ignoreSSLIssues: true,
});
it('should use a plain https agent with relaxed TLS when no proxy is configured', async () => {
mockTokenResponse({
status: 200,
headers: { contentType: 'application/json' },
body: JSON.stringify({
access_token: config.accessToken,
refresh_token: config.refreshToken,
}),
});
const axiosSpy = vi.spyOn(axios, 'request');
await makeIgnoreSSLCall();
const requestConfig = axiosSpy.mock.calls[0][0];
const httpsAgent = requestConfig.httpsAgent as HttpsAgent;
expect(httpsAgent).toBeInstanceOf(HttpsAgent);
expect(httpsAgent).not.toBeInstanceOf(HttpsProxyAgent);
expect(httpsAgent.options.rejectUnauthorized).toBe(false);
});
it('should route through an https proxy agent with relaxed TLS when HTTPS_PROXY is set', async () => {
process.env.HTTPS_PROXY = 'http://fake-proxy.example';
const axiosSpy = vi.spyOn(axios, 'request').mockResolvedValue({
status: 200,
headers: { contentType: 'application/json' },
data: JSON.stringify({
access_token: config.accessToken,
refresh_token: config.refreshToken,
}),
});
await makeIgnoreSSLCall();
const requestConfig = axiosSpy.mock.calls[0][0];
const httpsAgent = requestConfig.httpsAgent as HttpsProxyAgent<string>;
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
expect(httpsAgent.connectOpts.rejectUnauthorized).toBe(false);
// The ignore-SSL branch must keep axios's own proxy handling disabled
// so routing stays with our agent, not double-proxied.
expect(requestConfig.proxy).toBe(false);
});
it('should honor NO_PROXY and use a plain relaxed-TLS agent even when HTTPS_PROXY is set', async () => {
process.env.HTTPS_PROXY = 'http://fake-proxy.example';
process.env.NO_PROXY = new URL(config.baseUrl).hostname;
mockTokenResponse({
status: 200,
headers: { contentType: 'application/json' },
body: JSON.stringify({
access_token: config.accessToken,
refresh_token: config.refreshToken,
}),
});
const axiosSpy = vi.spyOn(axios, 'request');
await makeIgnoreSSLCall();
const requestConfig = axiosSpy.mock.calls[0][0];
const httpsAgent = requestConfig.httpsAgent as HttpsAgent;
expect(httpsAgent).toBeInstanceOf(HttpsAgent);
expect(httpsAgent).not.toBeInstanceOf(HttpsProxyAgent);
expect(httpsAgent.options.rejectUnauthorized).toBe(false);
});
it('should not set an httpsAgent when ignoreSSLIssues is false, leaving the global proxy agent in place', async () => {
process.env.HTTPS_PROXY = 'http://fake-proxy.example';
mockTokenResponse({
status: 200,
headers: { contentType: 'application/json' },
body: JSON.stringify({
access_token: config.accessToken,
refresh_token: config.refreshToken,
}),
});
const axiosSpy = vi.spyOn(axios, 'request');
await client.accessTokenRequest({
url: config.accessTokenUri,
method: 'POST',
headers: {
authorization: authHeader,
accept: 'application/json',
contentType: 'application/x-www-form-urlencoded',
},
body: { refresh_token: 'test', grant_type: 'refresh_token' },
ignoreSSLIssues: false,
});
const requestConfig = axiosSpy.mock.calls[0][0];
expect(requestConfig.httpsAgent).toBeUndefined();
expect(requestConfig.proxy).toBe(false);
});
});
});
describe('RFC 8707 resource parameter', () => {

View File

@ -6,8 +6,7 @@ export default mergeConfig(createVitestConfig({}), {
resolve: {
alias: [
// @inquirer/prompts and its sub-packages are ESM-only. Tests redirect
// any @inquirer/* import to this mock (mirrors the former Jest
// moduleNameMapper).
// any @inquirer/* import to this mock.
{
find: /^@inquirer\/.*$/,
replacement: path.resolve(__dirname, './src/__mocks__/@inquirer/prompts.ts'),

View File

@ -38,6 +38,15 @@ export class AgentsConfig {
@Env('N8N_AGENTS_CHECKPOINT_TTL')
checkpointTtlSeconds: number = 96 * Time.hours.toSeconds;
/**
* Whether agent runs emit OpenTelemetry spans. Rides along with the OTel
* module (endpoint, headers, sampling and transport are inherited from
* `N8N_OTEL_*`), so it has no effect when no OTel provider is registered.
* Lets operators run workflow OTel without agent spans.
*/
@Env('N8N_AGENTS_TRACING_ENABLED')
tracingEnabled: boolean = true;
/**
* Comma-separated list of agent sub-feature modules to enable. Each entry
* gates a specific frontend/runtime capability inside the agents module.

View File

@ -173,12 +173,15 @@ export class InstanceAiConfig {
runDebugEnabled: boolean = false;
/**
* EXPERIMENTAL: persist Instance AI events to a durable DB log
* (`instance_ai_events`) and serve SSE replay + history from it. Off =
* today's in-memory-only behavior. See RFC: instance-ai durable event log.
* Persist Instance AI events to a durable DB log (`instance_ai_events`)
* and serve SSE replay + history from it. Default on since Gate A of the
* durable-log rollout (pre-existing runs are backfilled by migration);
* `false` restores the legacy in-memory bus + stored-snapshot history as
* an off switch until the legacy paths sunset at Gate B. See RFC:
* instance-ai durable event log.
*/
@Env('N8N_INSTANCE_AI_DURABLE_LOG')
durableLog: boolean = false;
durableLog: boolean = true;
/** Enable extended thinking / reasoning for the orchestrator agent. */
@Env('N8N_INSTANCE_AI_THINKING_ENABLED')

View File

@ -355,7 +355,7 @@ describe('GlobalConfig', () => {
outputRedactionPlaceholder: '[REDACTED]',
runDebugEnabled: false,
thinkingEnabled: true,
durableLog: false,
durableLog: true,
},
queue: {
health: {
@ -650,6 +650,7 @@ describe('GlobalConfig', () => {
},
agents: {
checkpointTtlSeconds: 345600,
tracingEnabled: true,
modules: [],
sandboxEnabled: false,
sandboxProvider: '',
@ -672,6 +673,15 @@ describe('GlobalConfig', () => {
expect(readFileSyncMock).not.toHaveBeenCalled();
});
it('should parse N8N_AGENTS_TRACING_ENABLED from env variables', () => {
process.env = {
N8N_AGENTS_TRACING_ENABLED: 'false',
};
const config = Container.get(GlobalConfig);
expect(config.agents.tracingEnabled).toBe(false);
});
it('should parse N8N_AGENTS_AI_SANDBOX_EPHEMERAL from env variables', () => {
process.env = {
N8N_AGENTS_AI_SANDBOX_EPHEMERAL: 'true',

View File

@ -1,10 +1,10 @@
import WebSocketMock from 'vitest-websocket-mock';
import { CRDTEngine, createCRDTProvider } from '../index';
import { createSyncProvider } from '../sync/base-sync-provider';
import type { CRDTDoc, CRDTMap } from '../types';
import { MessagePortTransport } from './message-port';
import { WebSocketTransport } from './websocket';
import { createSyncProvider } from '../sync/base-sync-provider';
import type { CRDTDoc, CRDTMap } from '../types';
/**
* Test: Does onUpdate fire for applied remote updates?

View File

@ -60,7 +60,8 @@
"@vitest/coverage-v8": "catalog:",
"express": "catalog:",
"testcontainers": "catalog:",
"typescript": "catalog:",
"@typescript/native": "catalog:typescript-tooling",
"typescript": "catalog:typescript-tooling",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"

View File

@ -144,6 +144,7 @@ async function provision(dbType) {
process.env.DB_TABLE_PREFIX = '';
return {
dataSourceOptions: { type: 'postgres', ...conn },
containerId: container.getId(),
cleanup: () => container.stop(),
};
}
@ -156,10 +157,14 @@ function buildDsn(dbType, provisioned, docker) {
return `sqlite://${filePath}`;
}
const conn = provisioned.dataSourceOptions;
// Under Docker, tbls runs in its own container and reaches the host-mapped
// Postgres port via host.docker.internal (added below with --add-host).
const host = docker ? 'host.docker.internal' : conn.host;
return `postgres://${conn.username}:${conn.password}@${host}:${conn.port}/${conn.database}?sslmode=disable&search_path=public`;
// Under Docker, tbls shares the Postgres container's network namespace
// (--network container:<id> below), so it connects to the unmapped port on
// localhost. Going through the host-mapped port instead would break on
// hosts whose firewall drops container→host traffic (e.g. nftables setups).
if (docker) {
return `postgres://${conn.username}:${conn.password}@127.0.0.1:5432/${conn.database}?sslmode=disable&search_path=public`;
}
return `postgres://${conn.username}:${conn.password}@${conn.host}:${conn.port}/${conn.database}?sslmode=disable&search_path=public`;
}
/** Pre-pulls the tbls image, retrying on transient registry/network errors. */
@ -181,7 +186,7 @@ async function pullImageWithRetry(image, env, attempts = 3) {
}
/** Invokes tbls (binary locally, Docker image in CI). */
async function tbls(command, dbType, dsn, docker) {
async function tbls(command, dbType, dsn, docker, networkContainerId) {
const config = `.tbls.${dbType}.yml`;
const env = { ...process.env, TBLS_DSN: dsn };
const args = command === 'diff' ? ['diff'] : ['doc', '--force'];
@ -195,8 +200,9 @@ async function tbls(command, dbType, dsn, docker) {
const dockerArgs = [
'run',
'--rm',
'--add-host',
'host.docker.internal:host-gateway',
// Join the DB container's network namespace so the DSN's localhost port
// resolves inside it — no dependency on container→host connectivity.
...(networkContainerId ? ['--network', `container:${networkContainerId}`] : []),
'-e',
`TBLS_DSN=${dsn}`,
'-v',
@ -282,7 +288,7 @@ async function main() {
await dataSource.destroy();
const dsn = buildDsn(dbType, provisioned, docker);
const { code, stdout } = await tbls(command, dbType, dsn, docker);
const { code, stdout } = await tbls(command, dbType, dsn, docker, provisioned.containerId);
if (command === 'diff') {
// `tbls diff` prints the unified diff to stdout and exits non-zero when

View File

@ -0,0 +1,597 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
/**
* Durable-log Gate A (INS-851): backfill `instance_ai_events` for runs that
* predate the log, so the fold-on-read history path covers every run when the
* `N8N_INSTANCE_AI_DURABLE_LOG` default flips on in the same release. Without
* this, threads that straddle the flip lose their old turns' trees: the fold
* deliberately has no snapshot/log merge layer, and its whole-thread
* stored-snapshot fallback only fires for threads with ZERO event rows.
*
* Synthesis rules (mirrors what the live coalescer would have written):
* - Unit of work = stored run snapshots (`instance_ai_run_snapshots`); runs
* are not identifiable from messages alone. A snapshot's tree is decomposed
* into the canonical event sequence: `run-start`, timeline-ordered
* `text-block`/`reasoning-block`/`tool-call`(+terminal)/`agent-spawned`
* (+`agent-completed`), `run-finish`. In-flight tool calls get no terminal
* fact: the `run-finish` terminalizes them at fold time, preserving the
* "was interrupted" rendering.
* - Runs that already have event rows are skipped, so the migration is
* idempotent and re-runnable (Gate B re-runs it as a safety check).
* - Synthesized runs are always TERMINAL-COMPLETE (`run-start` paired with a
* `run-finish`), which structurally excludes them from the interrupted-run
* sweeper (`findUnfinishedRuns` matches run-start without run-finish) and
* from any future crash-resume. The `synthetic` marker is embedded in the
* payloads: run-start `messageId` and block `responseId`s carry a
* `backfill:` prefix, queryable without a schema change.
* - Threads with no snapshots and no events synthesize a flat run per
* assistant message (pseudo-run keyed by the message id), matching the
* parser's flat-fallback rendering so Gate B can delete that ladder.
* - Row `createdAt` = snapshot `createdAt` (parent-run end): the fold anchors
* entries on the row timestamp and the parser orphans entries dated
* strictly before their assistant message, so historical anchoring must be
* preserved. `seq` continues after the thread's current maximum; the fold
* sorts derived entries by `createdAt`, so mixed threads pair correctly.
*
* Deliberately not reproduced (degrade gracefully, documented in INS-851):
* confirmation cards (resolved long ago; their tool calls + results ARE
* reproduced, and re-emitting request facts would resurrect dead approval
* prompts), per-call timing (synthesized facts share the snapshot timestamp,
* so durations render as instant the tree stores no segment timing to do
* better), and langsmith feedback anchors (they live in snapshot COLUMNS,
* which outlive Gate B until the table drops, so feedback on pre-log threads
* keeps resolving unchanged; the Gate B anchor relocation carries them over).
*/
// ---------------------------------------------------------------------------
// Structural types for the stored tree JSON (kept local: migrations must not
// import app schema code, which evolves; the shape below is the frozen
// contract of what snapshots contained at flip time).
// ---------------------------------------------------------------------------
interface TreeToolCall {
toolCallId?: string;
toolName?: string;
args?: Record<string, unknown>;
result?: unknown;
error?: string;
isLoading?: boolean;
}
interface TreeTimelineEntry {
type?: string;
content?: string;
toolCallId?: string;
agentId?: string;
}
interface TreeNode {
agentId?: string;
role?: string;
status?: string;
textContent?: string;
reasoning?: string;
toolCalls?: TreeToolCall[];
children?: TreeNode[];
timeline?: TreeTimelineEntry[];
tools?: string[];
taskId?: string;
kind?: string;
title?: string;
subtitle?: string;
goal?: string;
targetResource?: { type?: unknown } & Record<string, unknown>;
tasks?: { tasks?: unknown };
planItems?: unknown[];
result?: string;
error?: string;
cancellationReason?: string;
}
export interface BackfillSnapshotRow {
runId: string;
messageGroupId: string | null;
/** simple-json column: all runIds of a merged message group. */
runIds: string[] | null;
tree: string | null;
createdAt: Date;
}
export interface BackfillMessageRow {
id: string;
role: string;
content: string;
createdAt: Date;
}
export interface SynthesizedRow {
runId: string;
type: string;
/** JSON.stringify of the canonical InstanceAiEvent. */
payload: string;
createdAt: Date;
}
// ---------------------------------------------------------------------------
// Pure synthesis helpers (exported for the Gate B safety re-run and for
// offline dry-run tooling that validates the output against the real event
// schema and shared reducer).
// ---------------------------------------------------------------------------
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
/** Minimal mirror of the parser's text extraction: string content or `text` parts. */
export function extractMessageText(content: string): string {
if (!content.startsWith('[') && !content.startsWith('{')) return content;
try {
const parsed: unknown = JSON.parse(content);
if (!Array.isArray(parsed)) return content;
return parsed
.map((part: unknown) =>
part !== null &&
typeof part === 'object' &&
(part as { type?: unknown }).type === 'text' &&
isNonEmptyString((part as { text?: unknown }).text)
? (part as { text: string }).text
: '',
)
.join('');
} catch {
return content;
}
}
function mapRunFinish(node: TreeNode): { status: string; reason?: string } {
const inverseCancellation: Record<string, string> = {
user: 'user_cancelled',
timeout: 'timeout',
shutdown: 'service_shutdown',
interrupted: 'crash_interrupted',
};
switch (node.status) {
case 'completed':
return { status: 'completed' };
case 'cancelled': {
const reason = node.cancellationReason
? inverseCancellation[node.cancellationReason]
: undefined;
return { status: 'cancelled', ...(reason ? { reason } : {}) };
}
case 'error':
return { status: 'error', ...(node.error ? { reason: node.error } : {}) };
default:
// A tree frozen mid-flight (pre-sweep crash): terminal-complete is a
// hard requirement, so it becomes an interrupted run.
return { status: 'interrupted', reason: 'backfill_unterminated' };
}
}
class RunSynthesizer {
readonly rows: SynthesizedRow[] = [];
private blockCounter = 0;
constructor(
private readonly runId: string,
private readonly createdAt: Date,
) {}
push(type: string, agentId: string, payload: Record<string, unknown>, responseId?: string) {
this.rows.push({
runId: this.runId,
type,
payload: JSON.stringify({
type,
runId: this.runId,
agentId,
...(responseId ? { responseId } : {}),
ts: this.createdAt.getTime(),
payload,
}),
createdAt: this.createdAt,
});
}
nextResponseId(): string {
return `backfill:${this.runId}:${++this.blockCounter}`;
}
}
function emitToolCall(synth: RunSynthesizer, agentId: string, tc: TreeToolCall): void {
if (!isNonEmptyString(tc.toolCallId) || !isNonEmptyString(tc.toolName)) return;
synth.push('tool-call', agentId, {
toolCallId: tc.toolCallId,
toolName: tc.toolName,
args: tc.args && typeof tc.args === 'object' ? tc.args : {},
});
if (isNonEmptyString(tc.error)) {
synth.push('tool-error', agentId, { toolCallId: tc.toolCallId, error: tc.error });
} else if (tc.result !== undefined && tc.isLoading !== true) {
synth.push('tool-result', agentId, { toolCallId: tc.toolCallId, result: tc.result });
}
// In-flight calls (isLoading, no result/error) get no terminal fact — the
// synthesized run-finish terminalizes them at fold time.
}
function emitChild(
synth: RunSynthesizer,
parentId: string,
child: TreeNode,
emitNode: (node: TreeNode, agentId: string) => void,
): void {
if (!isNonEmptyString(child.agentId)) return;
const target =
child.targetResource && isNonEmptyString(child.targetResource.type)
? child.targetResource
: undefined;
synth.push('agent-spawned', child.agentId, {
parentId,
role: isNonEmptyString(child.role) ? child.role : 'agent',
tools: Array.isArray(child.tools) ? child.tools.filter(isNonEmptyString) : [],
...(isNonEmptyString(child.taskId) ? { taskId: child.taskId } : {}),
...(isNonEmptyString(child.kind) ? { kind: child.kind } : {}),
...(isNonEmptyString(child.title) ? { title: child.title } : {}),
...(isNonEmptyString(child.subtitle) ? { subtitle: child.subtitle } : {}),
...(isNonEmptyString(child.goal) ? { goal: child.goal } : {}),
...(target ? { targetResource: target } : {}),
});
emitNode(child, child.agentId);
// Close every terminal child. `result` is schema-required (empty string is
// the live paths' convention). `agent-completed` carries no cancelled state,
// so a cancelled child closes as an error with a "Cancelled" marker — the
// same shape the live cancel path publishes. Children stored `active`
// (crashed mid-child, pre-sweep era) stay active: that is exactly what the
// flag-off snapshot rendered, so reproducing it is parity, not a regression.
if (child.status === 'completed' || child.status === 'error' || child.status === 'cancelled') {
const error = isNonEmptyString(child.error)
? child.error
: child.status === 'error'
? 'Failed'
: child.status === 'cancelled'
? 'Cancelled'
: undefined;
synth.push('agent-completed', child.agentId, {
role: isNonEmptyString(child.role) ? child.role : 'agent',
result: isNonEmptyString(child.result) ? child.result : '',
...(error ? { error } : {}),
});
}
}
function emitNodeContent(synth: RunSynthesizer, node: TreeNode, agentId: string): void {
const toolCallsById = new Map<string, TreeToolCall>();
for (const tc of node.toolCalls ?? []) {
if (isNonEmptyString(tc.toolCallId)) toolCallsById.set(tc.toolCallId, tc);
}
const childrenById = new Map<string, TreeNode>();
for (const child of node.children ?? []) {
if (isNonEmptyString(child.agentId)) childrenById.set(child.agentId, child);
}
const emitted = new Set<string>();
const recurse = (childNode: TreeNode, childAgentId: string) =>
emitNodeContent(synth, childNode, childAgentId);
const timeline = Array.isArray(node.timeline) ? node.timeline : [];
// Mirror the reducer's normalizeLegacyReasoningTimeline: trees persisted
// before reasoning became a timeline entry (or from the interim era where
// the timeline carried text/tools but not reasoning) hold the text only in
// the aggregate field. The read path unshifts it; the synthesis emits it
// first, so the folded aggregate matches the normalized stored tree.
const timelineHasReasoning = timeline.some(
(entry) => entry.type === 'reasoning' && isNonEmptyString(entry.content),
);
if (isNonEmptyString(node.reasoning) && !timelineHasReasoning) {
synth.push('reasoning-block', agentId, { text: node.reasoning }, synth.nextResponseId());
}
if (timeline.length > 0) {
for (const entry of timeline) {
if (entry.type === 'text' && isNonEmptyString(entry.content)) {
synth.push('text-block', agentId, { text: entry.content }, synth.nextResponseId());
} else if (entry.type === 'reasoning' && isNonEmptyString(entry.content)) {
synth.push('reasoning-block', agentId, { text: entry.content }, synth.nextResponseId());
} else if (entry.type === 'tool-call' && isNonEmptyString(entry.toolCallId)) {
const tc = toolCallsById.get(entry.toolCallId);
if (tc && !emitted.has(entry.toolCallId)) {
emitted.add(entry.toolCallId);
emitToolCall(synth, agentId, tc);
}
} else if (entry.type === 'child' && isNonEmptyString(entry.agentId)) {
const child = childrenById.get(entry.agentId);
if (child && !emitted.has(entry.agentId)) {
emitted.add(entry.agentId);
emitChild(synth, agentId, child, recurse);
}
}
}
} else if (isNonEmptyString(node.textContent)) {
// Legacy snapshots without a timeline: aggregate text (the aggregate
// reasoning was already emitted above).
synth.push('text-block', agentId, { text: node.textContent }, synth.nextResponseId());
}
// Anything the timeline did not reference still renders (defensive).
for (const [toolCallId, tc] of toolCallsById) {
if (!emitted.has(toolCallId)) emitToolCall(synth, agentId, tc);
}
for (const [childAgentId, child] of childrenById) {
if (!emitted.has(childAgentId)) emitChild(synth, agentId, child, recurse);
}
// Task/plan card: the tree stores the LATEST list, which is exactly what a
// last-write-wins tasks-update carries, so one synthesized fact restores it.
if (node.tasks && typeof node.tasks === 'object' && Array.isArray(node.tasks.tasks)) {
synth.push('tasks-update', agentId, {
tasks: node.tasks,
...(Array.isArray(node.planItems) && node.planItems.length > 0
? { planItems: node.planItems }
: {}),
});
}
}
function parseTree(tree: string | null): TreeNode | null {
if (!isNonEmptyString(tree)) return null;
try {
const parsed: unknown = JSON.parse(tree);
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as TreeNode;
}
} catch {
// fall through
}
return null;
}
function synthesizeSnapshotRun(snapshot: BackfillSnapshotRow): {
rows: SynthesizedRow[];
status: string;
} {
const tree = parseTree(snapshot.tree);
const synth = new RunSynthesizer(snapshot.runId, snapshot.createdAt);
const rootAgentId = isNonEmptyString(tree?.agentId)
? tree.agentId
: `orchestrator-${snapshot.runId}`;
synth.push('run-start', rootAgentId, {
messageId: `backfill:${snapshot.runId}`,
...(isNonEmptyString(snapshot.messageGroupId)
? { messageGroupId: snapshot.messageGroupId }
: {}),
});
if (tree) emitNodeContent(synth, tree, rootAgentId);
// Degenerate/unparseable trees fall through to lifecycle-only rows: the run
// is marked backfilled (metric reaches zero) and history renders text-only,
// matching what the flag-off path rendered for the same snapshot.
const finish = tree ? mapRunFinish(tree) : { status: 'completed' };
synth.push('run-finish', rootAgentId, finish);
return { rows: synth.rows, status: finish.status };
}
function synthesizeLifecycleOnlyRun(
runId: string,
messageGroupId: string | null,
status: string,
createdAt: Date,
): SynthesizedRow[] {
const synth = new RunSynthesizer(runId, createdAt);
const agentId = `orchestrator-${runId}`;
synth.push('run-start', agentId, {
messageId: `backfill:${runId}`,
...(isNonEmptyString(messageGroupId) ? { messageGroupId } : {}),
});
synth.push('run-finish', agentId, { status });
return synth.rows;
}
function synthesizeFlatMessageRun(message: BackfillMessageRow): SynthesizedRow[] {
const text = extractMessageText(message.content);
if (!isNonEmptyString(text)) return [];
const synth = new RunSynthesizer(message.id, message.createdAt);
const agentId = `orchestrator-${message.id}`;
synth.push('run-start', agentId, { messageId: `backfill:${message.id}` });
synth.push('text-block', agentId, { text }, synth.nextResponseId());
synth.push('run-finish', agentId, { status: 'completed' });
return synth.rows;
}
/**
* Synthesize the event rows one thread is missing. `seq` assignment is the
* caller's job (rows are returned in insertion order).
*/
export function synthesizeThreadEvents(input: {
snapshots: BackfillSnapshotRow[];
assistantMessages: BackfillMessageRow[];
existingRunIds: ReadonlySet<string>;
}): SynthesizedRow[] {
const rows: SynthesizedRow[] = [];
const covered = new Set<string>(input.existingRunIds);
const snapshots = [...input.snapshots].sort(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
);
for (const snapshot of snapshots) {
if (!isNonEmptyString(snapshot.runId)) continue;
let status: string;
if (covered.has(snapshot.runId)) {
// Primary run already log-covered (e.g. a flag-on run merged into a
// group with pre-log siblings): skip the tree synthesis but still
// derive the terminal status so uncovered siblings get their
// lifecycle rows below instead of counting as runs-without-events
// forever.
const tree = parseTree(snapshot.tree);
status = tree ? mapRunFinish(tree).status : 'completed';
} else {
covered.add(snapshot.runId);
const run = synthesizeSnapshotRun(snapshot);
rows.push(...run.rows);
status = run.status;
}
// Merged message groups: the tree already covers the whole group, but the
// sibling runIds must stop counting as runs-without-events. Lifecycle-only
// rows join the group (same messageGroupId) without duplicating content.
for (const extraRunId of snapshot.runIds ?? []) {
if (!isNonEmptyString(extraRunId) || covered.has(extraRunId)) continue;
covered.add(extraRunId);
rows.push(
...synthesizeLifecycleOnlyRun(
extraRunId,
snapshot.messageGroupId,
status,
snapshot.createdAt,
),
);
}
}
// Flat fallback: only when the thread has no snapshots at all AND no real
// rows — the shape the read-time parser ladder produced from bare messages.
if (input.snapshots.length === 0 && input.existingRunIds.size === 0) {
const messages = [...input.assistantMessages].sort(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
);
for (const message of messages) {
if (message.role !== 'assistant' || covered.has(message.id)) continue;
covered.add(message.id);
rows.push(...synthesizeFlatMessageRun(message));
}
}
return rows;
}
// ---------------------------------------------------------------------------
// Migration
// ---------------------------------------------------------------------------
export class BackfillInstanceAiEventLog1784000000051 implements IrreversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const snapshotsTable = escape.tableName('instance_ai_run_snapshots');
const messagesTable = escape.tableName('instance_ai_messages');
const eventsTable = escape.tableName('instance_ai_events');
const threadIdColumn = escape.columnName('threadId');
const runIdColumn = escape.columnName('runId');
const seqColumn = escape.columnName('seq');
const typeColumn = escape.columnName('type');
const payloadColumn = escape.columnName('payload');
const createdAtColumn = escape.columnName('createdAt');
const updatedAtColumn = escape.columnName('updatedAt');
const threadRows = await runQuery<Array<{ threadId: string }>>(`
SELECT ${threadIdColumn} AS ${escape.columnName('threadId')}
FROM ${snapshotsTable} GROUP BY ${threadIdColumn}
UNION
SELECT ${threadIdColumn} FROM ${messagesTable} GROUP BY ${threadIdColumn}
`);
for (const { threadId } of threadRows) {
const existing = await runQuery<Array<{ runId: string }>>(
`SELECT ${runIdColumn} AS ${escape.columnName('runId')}
FROM ${eventsTable} WHERE ${threadIdColumn} = :threadId GROUP BY ${runIdColumn}`,
{ threadId },
);
const existingRunIds = new Set(existing.map((r) => r.runId));
const snapshotRows = await runQuery<
Array<{
runId: string;
messageGroupId: string | null;
runIds: string | null;
tree: string | null;
createdAt: Date | string;
}>
>(
`SELECT ${runIdColumn} AS ${escape.columnName('runId')},
${escape.columnName('messageGroupId')} AS ${escape.columnName('messageGroupId')},
${escape.columnName('runIds')} AS ${escape.columnName('runIds')},
${escape.columnName('tree')} AS ${escape.columnName('tree')},
${createdAtColumn} AS ${escape.columnName('createdAt')}
FROM ${snapshotsTable} WHERE ${threadIdColumn} = :threadId`,
{ threadId },
);
const snapshots: BackfillSnapshotRow[] = snapshotRows.map((r) => ({
runId: r.runId,
messageGroupId: r.messageGroupId,
runIds: parseSimpleJsonArray(r.runIds),
tree: r.tree,
createdAt: parseDbDate(r.createdAt),
}));
let assistantMessages: BackfillMessageRow[] = [];
if (snapshots.length === 0 && existingRunIds.size === 0) {
const messageRows = await runQuery<
Array<{ id: string; role: string; content: string; createdAt: Date | string }>
>(
`SELECT ${escape.columnName('id')} AS ${escape.columnName('id')},
${escape.columnName('role')} AS ${escape.columnName('role')},
${escape.columnName('content')} AS ${escape.columnName('content')},
${createdAtColumn} AS ${escape.columnName('createdAt')}
FROM ${messagesTable}
WHERE ${threadIdColumn} = :threadId AND ${escape.columnName('role')} = 'assistant'`,
{ threadId },
);
assistantMessages = messageRows.map((r) => ({
id: r.id,
role: r.role,
content: r.content,
createdAt: parseDbDate(r.createdAt),
}));
}
const synthesized = synthesizeThreadEvents({ snapshots, assistantMessages, existingRunIds });
if (synthesized.length === 0) continue;
const maxSeqRows = await runQuery<Array<{ maxSeq: number | string | null }>>(
`SELECT MAX(${seqColumn}) AS ${escape.columnName('maxSeq')}
FROM ${eventsTable} WHERE ${threadIdColumn} = :threadId`,
{ threadId },
);
let seq = Number(maxSeqRows[0]?.maxSeq ?? 0) || 0;
for (const row of synthesized) {
seq += 1;
await runQuery(
`INSERT INTO ${eventsTable}
(${threadIdColumn}, ${seqColumn}, ${runIdColumn}, ${typeColumn}, ${payloadColumn}, ${createdAtColumn}, ${updatedAtColumn})
VALUES (:threadId, :seq, :runId, :type, :payload, :createdAt, :updatedAt)`,
{
threadId,
seq,
runId: row.runId,
type: row.type,
payload: row.payload,
createdAt: row.createdAt,
updatedAt: row.createdAt,
},
);
}
}
}
}
/**
* Raw-query date handling. node-pg returns Date objects; sqlite returns the
* stored UTC string WITHOUT a zone marker ('YYYY-MM-DD HH:MM:SS.mmm'), which
* `new Date()` would parse as LOCAL time and shift every backfilled row by
* the host's UTC offset — enough to break the parser's chronological pairing
* against message timestamps. Normalize the string to explicit UTC.
*/
function parseDbDate(value: Date | string): Date {
if (value instanceof Date) return value;
const utcIso = /^\d{4}-\d{2}-\d{2} /.test(value) ? `${value.replace(' ', 'T')}Z` : value;
return new Date(utcIso);
}
function parseSimpleJsonArray(value: string | string[] | null): string[] | null {
if (!value) return null;
// simple-json is a text column on both engines, but stay total in case a
// driver ever hands back an already-parsed array.
if (Array.isArray(value)) {
return value.filter((v): v is string => typeof v === 'string');
}
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : null;
} catch {
return null;
}
}

View File

@ -224,6 +224,7 @@ import { BackfillPreScopingOAuthGrantScopes1784000000047 } from '../common/17840
import { AddTriggerKindToWorkflowPublicationTriggerStatus1784000000048 } from '../common/1784000000048-AddTriggerKindToWorkflowPublicationTriggerStatus';
import { AddScheduledTaskDispatchedAt1784000000049 } from '../common/1784000000049-AddScheduledTaskDispatchedAt';
import { AddHostRunIdToInstanceAiCheckpoints1784000000050 } from '../common/1784000000050-AddHostRunIdToInstanceAiCheckpoints';
import { BackfillInstanceAiEventLog1784000000051 } from '../common/1784000000051-BackfillInstanceAiEventLog';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@ -453,4 +454,5 @@ export const postgresMigrations: Migration[] = [
AddTriggerKindToWorkflowPublicationTriggerStatus1784000000048,
AddScheduledTaskDispatchedAt1784000000049,
AddHostRunIdToInstanceAiCheckpoints1784000000050,
BackfillInstanceAiEventLog1784000000051,
];

View File

@ -216,6 +216,7 @@ import { BackfillPreScopingOAuthGrantScopes1784000000047 } from '../common/17840
import { AddTriggerKindToWorkflowPublicationTriggerStatus1784000000048 } from '../common/1784000000048-AddTriggerKindToWorkflowPublicationTriggerStatus';
import { AddScheduledTaskDispatchedAt1784000000049 } from '../common/1784000000049-AddScheduledTaskDispatchedAt';
import { AddHostRunIdToInstanceAiCheckpoints1784000000050 } from '../common/1784000000050-AddHostRunIdToInstanceAiCheckpoints';
import { BackfillInstanceAiEventLog1784000000051 } from '../common/1784000000051-BackfillInstanceAiEventLog';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@ -435,6 +436,7 @@ const sqliteMigrations: Migration[] = [
AddTriggerKindToWorkflowPublicationTriggerStatus1784000000048,
AddScheduledTaskDispatchedAt1784000000049,
AddHostRunIdToInstanceAiCheckpoints1784000000050,
BackfillInstanceAiEventLog1784000000051,
];
export { sqliteMigrations };

View File

@ -1,5 +1,5 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",

View File

@ -1,5 +1,5 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"types": ["node", "vitest/globals", "vite/client"],
"experimentalDecorators": true,

View File

@ -27,7 +27,7 @@
"@n8n/vitest-config": "workspace:*",
"@types/express": "catalog:",
"@types/lodash": "catalog:",
"typescript": "catalog:",
"typescript": "catalog:typescript",
"vitest": "catalog:",
"zod": "catalog:"
},

View File

@ -10,6 +10,7 @@ export type PubSubEventName =
| 'display-workflow-activation'
| 'display-workflow-deactivation'
| 'display-workflow-activation-error'
| 'display-workflow-publication-status'
| 'workflow-publish-wake-up'
| 'community-package-install'
| 'community-package-uninstall'

View File

@ -1,5 +1,5 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",

View File

@ -1,5 +1,5 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"types": ["node", "vitest/globals"],
"experimentalDecorators": true,

View File

@ -28,7 +28,7 @@
"devDependencies": {
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"typescript": "catalog:typescript",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
"@n8n/typescript-config": "workspace:*"

View File

@ -31,7 +31,7 @@ const instances = new Map<ServiceIdentifier, Metadata>();
* @returns A class decorator to be applied to the target class
*/
// eslint-disable-next-line @typescript-eslint/no-restricted-types
export function Service<T = unknown>(): Function;
export function Service(): Function;
// eslint-disable-next-line @typescript-eslint/no-restricted-types
export function Service<T = unknown>(options: Options<T>): Function;
export function Service<T>({ factory }: Options<T> = {}) {

View File

@ -1,5 +1,5 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",

View File

@ -1,5 +1,5 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"types": ["node", "vitest/globals"],
"experimentalDecorators": true,

View File

@ -38,6 +38,7 @@
"@types/eslint": "^9.6.1",
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "^8.35.0",
"@typescript-eslint/parser": "^8.35.0",
"@typescript-eslint/rule-tester": "^8.35.0",
"@typescript-eslint/utils": "^8.35.0",
"@typescript/native": "catalog:typescript-tooling",

View File

@ -42,6 +42,15 @@ export const baseConfig = tseslint.config(
},
settings: {
'import-x/resolver-next': [createTypeScriptImportResolver()],
// Neutralize the string-based parser mapping added by import-x's TS preset.
// A string parser path makes import-x re-`require('@typescript-eslint/parser')`
// when parsing imported modules, which resolves a parser copy peered to the
// leaf package's tsgo `typescript` (no programmatic API in TS7) and crashes
// reading `ts.Extension.Cjs`. ESLint deep-merges settings, so we can't drop
// the key — instead empty its extension list so import-x matches nothing here
// and falls back to the already-loaded parser object from languageOptions
// (backed by TS6).
'import-x/parsers': { '@typescript-eslint/parser': [] },
},
rules: {
// ******************************************************************

View File

@ -12,6 +12,15 @@ Prevents importing external dependencies that are not allowed on n8n Cloud. Comm
Relative imports (starting with `./` or `../`) are always allowed.
**Dev dependencies are permitted.** Modules listed in the package's
`devDependencies` (e.g. `vitest`, or type-only imports from a types
package) are never installed at runtime on n8n Cloud — only the built
`dist/` is shipped — so they are not runtime dependencies and are exempt
from this rule. (Runtime `dependencies` are separately forced to be
empty by `no-runtime-dependencies`.) This rule targets runtime
dependencies only: anything in `dependencies`, or any import not on the
allowlist and not relative, is restricted — including in test files.
## Examples
### ❌ Incorrect

View File

@ -1,7 +1,33 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll } from 'vitest';
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
// Fixture package whose devDependencies include `vitest` and `@vitest/expect`,
// and whose runtime `dependencies` include `axios`. Used to verify the rule
// allows dev-dependency imports but still restricts runtime dependencies.
// Created synchronously because RuleTester.run reads the `filename` option at
// module-eval time, before any test hooks fire.
const fixtureDir = mkdtempSync(join(tmpdir(), 'n8n-restricted-imports-'));
writeFileSync(
join(fixtureDir, 'package.json'),
JSON.stringify({
name: 'n8n-nodes-devdep-fixture',
version: '1.0.0',
devDependencies: { vitest: '^1.0.0', '@vitest/expect': '^1.0.0' },
dependencies: { axios: '^1.0.0' },
}),
);
const fixtureTestFile = join(fixtureDir, '__tests__', 'MyNode.test.ts');
const fixtureRealFile = join(fixtureDir, 'src', 'MyNode.node.ts');
afterAll(() => {
rmSync(fixtureDir, { recursive: true, force: true });
});
const ruleTester = new RuleTester();
ruleTester.run('no-restricted-imports', NoRestrictedImportsRule, {
@ -78,6 +104,31 @@ ruleTester.run('no-restricted-imports', NoRestrictedImportsRule, {
{
code: 'const workflow = await import(`n8n-workflow`);',
},
{
name: 'devDependency (vitest) import is allowed in test files',
filename: fixtureTestFile,
code: 'import { describe } from "vitest";',
},
{
name: 'scoped devDependency import is allowed in test files',
filename: fixtureTestFile,
code: 'import { expect } from "@vitest/expect";',
},
{
name: 'devDependency require is allowed in test files',
filename: fixtureTestFile,
code: 'const { it } = require("vitest");',
},
{
name: 'devDependency dynamic import is allowed in test files',
filename: fixtureTestFile,
code: 'const vitest = await import("vitest");',
},
{
name: 'type-only devDependency import is allowed in node source',
filename: fixtureRealFile,
code: 'import type { Task } from "vitest";',
},
],
invalid: [
{
@ -157,6 +208,18 @@ const lodash = require("lodash");`,
code: 'const express = await import("express");',
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'express' } }],
},
{
name: 'runtime dependency (axios) is still restricted in a real node file',
filename: fixtureRealFile,
code: 'import axios from "axios";',
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'axios' } }],
},
{
name: 'runtime dependency (axios) stays restricted even in test files',
filename: fixtureTestFile,
code: 'import axios from "axios";',
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'axios' } }],
},
{
code: 'const path = require(`path`);',
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'path' } }],

View File

@ -3,6 +3,8 @@ import {
isDirectRequireCall,
isRequireMemberCall,
createRule,
findPackageJson,
readPackageJsonDevDependencies,
} from '../utils/index.js';
const allowedModules = [
@ -18,13 +20,20 @@ const allowedModules = [
'@n8n/ai-node-sdk',
];
const isModuleAllowed = (modulePath: string): boolean => {
const isModuleAllowed = (modulePath: string, devDependencies: Set<string>): boolean => {
if (modulePath.startsWith('./') || modulePath.startsWith('../')) return true;
const moduleName = modulePath.startsWith('@')
? modulePath.split('/').slice(0, 2).join('/')
: modulePath.split('/')[0];
if (!moduleName) return true;
// Dev dependencies (e.g. `vitest`) are never installed at runtime on n8n
// Cloud, so they are not subject to this rule — it targets runtime
// dependencies only. `no-runtime-dependencies` already enforces that the
// package's `dependencies` field is empty, so any external package an
// author uses must be a devDependency (bundled at build) or a
// peerDependency (provided by the instance).
if (devDependencies.has(moduleName)) return true;
return allowedModules.includes(moduleName);
};
@ -47,10 +56,14 @@ export const NoRestrictedImportsRule = createRule({
},
defaultOptions: [],
create(context) {
const devDependencies = readPackageJsonDevDependencies(
findPackageJson(context.physicalFilename),
);
return {
ImportDeclaration(node) {
const modulePath = getModulePath(node.source);
if (modulePath && !isModuleAllowed(modulePath)) {
if (modulePath && !isModuleAllowed(modulePath, devDependencies)) {
context.report({
node,
messageId: 'restrictedImport',
@ -63,7 +76,7 @@ export const NoRestrictedImportsRule = createRule({
ImportExpression(node) {
const modulePath = getModulePath(node.source);
if (modulePath && !isModuleAllowed(modulePath)) {
if (modulePath && !isModuleAllowed(modulePath, devDependencies)) {
context.report({
node,
messageId: 'restrictedDynamicImport',
@ -77,7 +90,7 @@ export const NoRestrictedImportsRule = createRule({
CallExpression(node) {
if (isDirectRequireCall(node) || isRequireMemberCall(node)) {
const modulePath = getModulePath(node.arguments[0] ?? null);
if (modulePath && !isModuleAllowed(modulePath)) {
if (modulePath && !isModuleAllowed(modulePath, devDependencies)) {
context.report({
node,
messageId: 'restrictedRequire',

View File

@ -73,19 +73,40 @@ function isValidPackageJson(obj: unknown): obj is { n8n?: PackageJsonN8n } {
return typeof obj === 'object' && obj !== null;
}
function readPackageJsonN8n(packageJsonPath: string): PackageJsonN8n {
function readPackageJsonRaw(packageJsonPath: string): Record<string, unknown> | null {
try {
const content = readFileSync(packageJsonPath, 'utf8');
const parsed: unknown = JSON.parse(content);
if (isValidPackageJson(parsed)) {
return parsed.n8n ?? {};
}
return {};
return isValidPackageJson(parsed) ? (parsed as Record<string, unknown>) : null;
} catch {
return {};
return null;
}
}
function readPackageJsonN8n(packageJsonPath: string): PackageJsonN8n {
const parsed = readPackageJsonRaw(packageJsonPath);
if (parsed) {
const n8n = parsed.n8n;
return typeof n8n === 'object' && n8n !== null ? (n8n as PackageJsonN8n) : {};
}
return {};
}
/**
* Returns the set of package names listed under `devDependencies` in the given
* package.json. Dev dependencies are never installed at runtime on n8n Cloud
* (only the built `dist/` is shipped), so importing them is not a runtime
* dependency concern and is permitted by `no-restricted-imports`.
*/
export function readPackageJsonDevDependencies(packageJsonPath: string | null): Set<string> {
if (!packageJsonPath) return new Set();
const parsed = readPackageJsonRaw(packageJsonPath);
if (!parsed) return new Set();
const devDeps = parsed.devDependencies;
if (typeof devDeps !== 'object' || devDeps === null) return new Set();
return new Set(Object.keys(devDeps as Record<string, unknown>));
}
function resolveN8nFilePaths(packageJsonPath: string, filePaths: string[]): string[] {
const packageDir = dirname(packageJsonPath);
const resolvedFiles: string[] = [];

View File

@ -8,6 +8,7 @@
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"node": "./dist/cjs/index.js",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},

View File

@ -51,7 +51,8 @@
"@vue/tsconfig": "catalog:frontend",
"prettier": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"@typescript/native": "catalog:typescript-tooling",
"typescript": "catalog:typescript-tooling",
"rimraf": "catalog:",
"vite": "catalog:",
"vue": "catalog:frontend",

View File

@ -1,9 +1,10 @@
import { extensionManifestSchema } from '../src/schema';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { writeFile } from 'fs/promises';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { format, resolveConfig } from 'prettier';
import { fileURLToPath } from 'url';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { extensionManifestSchema } from '../src/schema.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(__dirname, '..');

View File

@ -1,2 +1,2 @@
export * from './define';
export type * from './types';
export * from './define.js';
export type * from './types.ts';

View File

@ -1 +1 @@
export * from './schema';
export * from './schema.js';

View File

@ -1,5 +1,5 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"composite": true,
"declaration": true,

View File

@ -1,5 +1,5 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"composite": true,
"declaration": true,

View File

@ -1,5 +1,5 @@
{
"extends": "@n8n/typescript-config/tsconfig.backend.json",
"extends": "@n8n/typescript-config/tsconfig.backend.go.json",
"compilerOptions": {
"noEmit": true
},

View File

@ -275,11 +275,12 @@ The event bus decouples agent execution from event delivery:
- All events carry `runId` (correlates to triggering message) and `agentId`
- SSE events use monotonically increasing per-thread `id` values for replay
- SSE supports both `Last-Event-ID` header and `?lastEventId` query parameter
- Event storage depends on `N8N_INSTANCE_AI_DURABLE_LOG`: off (default), events
live only in a bounded in-memory buffer (500 events / 2 MB per thread,
FIFO-evicted, ids reset on restart); on, coalesced step-level facts are
appended to the `instance_ai_events` table (the durable replay source, ids
survive restarts) while token deltas stay memory-only
- Event storage depends on `N8N_INSTANCE_AI_DURABLE_LOG`: on (the default),
coalesced step-level facts are appended to the `instance_ai_events` table
(the durable replay source, ids survive restarts) while token deltas stay
memory-only; off (the rollback switch until Gate B), events live only in a
bounded in-memory buffer (500 events / 2 MB per thread, FIFO-evicted, ids
reset on restart)
- No need to pipe sub-agent streams through orchestrator tool execution
- One active run per thread (additional `POST /chat` is rejected while active)
- Cancellation via `POST /instance-ai/chat/:threadId/cancel` (idempotent)

View File

@ -167,13 +167,15 @@ The event bus transport is selected automatically:
- **Queue mode**: Redis Pub/Sub — uses n8n's existing Redis connection
Event persistence is controlled by `N8N_INSTANCE_AI_DURABLE_LOG` (default
`false`). Off, events live only in a bounded in-memory buffer per thread
(500 events / 2 MB, FIFO-evicted; ids reset on restart, so replay does not
survive a restart). On, coalesced step-level facts (completed text/reasoning
blocks, tool calls and results, run lifecycle) are appended to the
`instance_ai_events` table and replay reads the database; token deltas are
never persisted. Rows cascade-delete with their thread
(`N8N_INSTANCE_AI_THREAD_TTL_DAYS`).
`true` since Gate A of the durable-log rollout; pre-existing runs are
backfilled by migration). On, coalesced step-level facts (completed
text/reasoning blocks, tool calls and results, run lifecycle) are appended to
the `instance_ai_events` table and replay reads the database; token deltas
are never persisted. Rows cascade-delete with their thread
(`N8N_INSTANCE_AI_THREAD_TTL_DAYS`). Setting it to `false` is the rollback
switch until the legacy paths sunset at Gate B: events then live only in a
bounded in-memory buffer per thread (500 events / 2 MB, FIFO-evicted; ids
reset on restart, so replay does not survive a restart).
Runtime behavior:
- One active run per thread. Additional `POST /instance-ai/chat/:threadId`

View File

@ -412,12 +412,13 @@ simultaneously persisted to thread storage and delivered to connected SSE client
| Single instance | In-process `EventEmitter` | Zero infrastructure |
| Queue mode | Redis Pub/Sub | n8n already uses Redis |
Replay storage depends on `N8N_INSTANCE_AI_DURABLE_LOG`. Off (default),
replay serves from a bounded in-memory buffer per thread (500 events / 2 MB,
FIFO-evicted; ids reset on restart). On, the durable event log
(`instance_ai_events`) is the replay source: coalesced step-level facts are
appended with a per-thread `seq` assigned by the writer's drain, so cursors
stay valid across restarts and across mains sharing one database.
Replay storage depends on `N8N_INSTANCE_AI_DURABLE_LOG`. On (the default),
the durable event log (`instance_ai_events`) is the replay source: coalesced
step-level facts are appended with a per-thread `seq` assigned by the
writer's drain, so cursors stay valid across restarts and across mains
sharing one database. Off (the rollback switch until Gate B), replay serves
from a bounded in-memory buffer per thread (500 events / 2 MB, FIFO-evicted;
ids reset on restart).
### Reconnection & Replay (Canonical Rule)

View File

@ -685,8 +685,11 @@ Delegates agent building to the agents-module builder chat
turn per call. Registered in `createOrchestrationTools` only when the host
provides `builderDelegate` (agents module active). The builder's own prompt
and tools drive the build, including its interactive tools (`ask_questions`,
`ask_credential`, `ask_embedding_credential`, `configure_channel`) — the
sub-agent session no longer excludes them. Builder session state is keyed to
`ask_credential`, `ask_embedding_credential`, `configure_channel`) and
lifecycle tools (`publish_agent`, `unpublish_agent`) on the bound target agent —
the sub-agent session no longer excludes them. Forward publish/unpublish/
activate/make-live intents to `build-agent`; never tell the user to open the
agent editor and click Publish. Builder session state is keyed to
instance-AI-scoped threads (`ia-builder:<threadId>:<agentId>`) and never
appears in the agents-module builder UI.

View File

@ -97,6 +97,76 @@ describe('aggregateResults — verifier-incomplete scenario runs', () => {
});
});
describe('aggregateResults — case verification status', () => {
it('marks a case notVerified when every scenario run is incomplete', () => {
const allRuns = [
[makeRunResult({ success: false, incomplete: true })],
[makeRunResult({ success: false, incomplete: true })],
];
const evaluation = aggregateResults(allRuns, 2);
expect(evaluation.testCases[0].status).toBe('notVerified');
});
it('marks a case verified when at least one scenario run was evaluated', () => {
const allRuns = [
[makeRunResult({ success: true })],
[makeRunResult({ success: false, incomplete: true })],
];
const evaluation = aggregateResults(allRuns, 2);
expect(evaluation.testCases[0].status).toBe('verified');
});
it('marks a build-failed case verified — a build failure is a verified failure, not a gap', () => {
// Build failures substitute a non-incomplete "scenario not executed" result,
// so they count as evaluated failures rather than an unverifiable gap.
const buildFailedCase: WorkflowTestCase = {
...incompleteTestCase,
};
const allRuns = [
[
{
testCase: buildFailedCase,
workflowBuildSuccess: false,
executionScenarioResults: [],
},
],
];
const evaluation = aggregateResults(allRuns, 1);
expect(evaluation.testCases[0].executionScenarios[0].evaluatedCount).toBe(1);
expect(evaluation.testCases[0].status).toBe('verified');
});
it('marks a case notVerified when its only expectations were skipped (prebuilt process gap)', () => {
// A process-only case run without a transcript (prebuilt/MCP) judges nothing:
// the expectation is absent from every run, so nothing could be verified.
const processOnlyCase: WorkflowTestCase = {
...incompleteTestCase,
executionScenarios: undefined,
processExpectations: ['asks before building'],
};
const allRuns = [
[
{
testCase: processOnlyCase,
workflowBuildSuccess: true,
executionScenarioResults: [],
buildExpectationResults: [],
},
],
];
const evaluation = aggregateResults(allRuns, 1);
expect(evaluation.testCases[0].status).toBe('notVerified');
});
});
describe('aggregateResults — build expectations as units', () => {
const expectationCase: WorkflowTestCase = {
...incompleteTestCase,

View File

@ -75,6 +75,7 @@ function fixture(): { evaluation: MultiRunEvaluation; withFiles: WorkflowTestCas
expectationAggregation('asks before building', 2, 3),
expectationAggregation('never judged', 0, 0),
],
status: 'verified',
},
],
};

View File

@ -0,0 +1,131 @@
import { vi } from 'vitest';
import type { N8nClient } from '../clients/n8n-client';
import type { EvalLogger } from '../harness/logger';
import { buildWorkflow } from '../harness/runner';
import { buildAgentOutcome } from '../outcome/workflow-discovery';
import type { ExecutionScenario } from '../types';
// The chat loop is network/SSE machinery irrelevant to this test: stub it so a
// single-turn build reaches the pre-build seed + outcome steps without real I/O.
// (vi.mock is hoisted above the imports, so the runner picks up these stubs.)
vi.mock('../harness/chat-loop', () => ({
SSE_SETTLE_DELAY_MS: 0,
startSseConnection: vi.fn().mockResolvedValue(undefined),
waitForAllActivity: vi.fn().mockResolvedValue(undefined),
runMultiTurnConversation: vi.fn().mockResolvedValue(undefined),
recordUserTurn: vi.fn(),
}));
// Force a "workflow built" outcome by default so the build succeeds; individual
// tests override it to simulate a build-step failure after pre-seeding.
vi.mock('../outcome/workflow-discovery', () => ({
buildAgentOutcome: vi.fn().mockResolvedValue({
workflowsCreated: [{ id: 'built-wf-1', name: 'Built', nodeCount: 3, active: false }],
executionsRun: [],
dataTablesCreated: ['built-dt-1'],
finalText: 'done',
workflowJsons: [{ id: 'built-wf-1', name: 'Built', nodes: [], connections: {} }],
}),
extractWorkflowIdsFromMessages: vi.fn().mockReturnValue([]),
}));
const silentLogger: EvalLogger = {
info: () => {},
verbose: () => {},
success: () => {},
warn: () => {},
error: () => {},
isVerbose: false,
};
function scenarioWithSeedTable(): ExecutionScenario {
return {
name: 'scenario',
description: 'd',
dataSetup: 'setup',
successCriteria: 'ok',
seedDataTables: [
{
id: 'job-applications-1234',
name: 'Job Applications',
columns: [{ name: 'id', type: 'string' as const }],
rows: [{ id: 'row_001' }],
},
],
};
}
function makeClient(overrides: Partial<Record<keyof N8nClient, unknown>> = {}): N8nClient {
return {
getPersonalProjectId: vi.fn().mockResolvedValue('project-1'),
ensureThread: vi.fn().mockResolvedValue(undefined),
setThreadCredentialAllowlist: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
getThreadMessages: vi.fn().mockResolvedValue({ messages: [] }),
// Pre-build scenario-table creation returns the real id under the name.
restoreThread: vi
.fn()
.mockResolvedValue({ restored: 0, workflowIds: [], dataTableIds: ['scenario-dt-1'] }),
...overrides,
} as unknown as N8nClient;
}
const baseConfig = {
conversation: [{ role: 'user' as const, text: 'build a workflow' }],
executionScenarios: [scenarioWithSeedTable()],
skipWorkflowChecks: true,
preRunWorkflowIds: new Set<string>(),
claimedWorkflowIds: new Set<string>(),
logger: silentLogger,
};
// TRUST-311 follow-up: scenario data tables are created (empty) BEFORE the build
// turn. These pin the failure/cleanup contract of that pre-build seeding:
// - a create failure fails the build as a harness problem (framework_issue),
// and there is nothing built to leak;
// - if a LATER build step fails, the already-created tables are still handed to
// cleanup (folded into restoredDataTableIds) rather than leaking.
describe('buildWorkflow scenario-seed data table lifecycle', () => {
it('fails the build and flags seedingFailed when pre-build table creation fails', async () => {
const client = makeClient({
restoreThread: vi.fn().mockRejectedValue(new Error('seed insert failed')),
});
const build = await buildWorkflow({ client, ...baseConfig });
expect(build.success).toBe(false);
// A pre-seed failure is a harness problem, not an agent build failure — flag
// it so the CLI attributes framework_issue, not build_failure.
expect(build.seedingFailed).toBe(true);
// The build never ran, so there is nothing built to leak.
expect(build.createdWorkflowIds).toEqual([]);
expect(build.createdDataTableIds).toEqual([]);
});
it('hands the pre-created scenario tables to cleanup when a later build step fails', async () => {
vi.mocked(buildAgentOutcome).mockRejectedValueOnce(new Error('workflow discovery failed'));
const client = makeClient(); // pre-seed succeeds → scenario-dt-1 created
const build = await buildWorkflow({ client, ...baseConfig });
expect(build.success).toBe(false);
// The pre-created table must still be returned so the caller's cleanup
// deletes it instead of leaking it into the shared project.
expect(build.createdDataTableIds).toContain('scenario-dt-1');
});
it('returns the built workflow, both tables, and the name→id map on success', async () => {
const client = makeClient();
const build = await buildWorkflow({ client, ...baseConfig });
expect(build.success).toBe(true);
expect(build.createdWorkflowIds).toContain('built-wf-1');
expect(build.createdDataTableIds).toEqual(
expect.arrayContaining(['built-dt-1', 'scenario-dt-1']),
);
// The name→real-id map lets each scenario reseed rows into the bound table.
expect(build.seededScenarioTableIdsByName).toEqual({ 'Job Applications': 'scenario-dt-1' });
});
});

View File

@ -132,6 +132,11 @@ function evaluation(
})),
buildSuccessCount,
buildExpectations,
status:
scenarios.some((sa) => sa.evaluatedCount > 0) ||
buildExpectations.some((ea) => ea.evaluatedCount > 0)
? ('verified' as const)
: ('notVerified' as const),
};
}),
};

View File

@ -97,6 +97,11 @@ function makeEval(totalRuns: number, cases: CaseSpec[]) {
buildSuccessCount: totalRuns,
executionScenarios: scenarioAggs,
buildExpectations,
status:
scenarioAggs.some((sa) => sa.evaluatedCount > 0) ||
buildExpectations.some((ea) => ea.evaluatedCount > 0)
? ('verified' as const)
: ('notVerified' as const),
};
});
const evaluation: MultiRunEvaluation = { totalRuns, testCases };

View File

@ -0,0 +1,92 @@
import { EvalTestCaseSchema } from '../harness/schema';
// TRUST-311: an execution scenario can declare typed seed data tables (with rows)
// so a string id like `row_001` lands in a `string` column instead of being
// rejected by a `number` column. The field reuses api-types'
// `instanceAiEvalSeedDataTableSchema`, extended to carry optional `rows`.
describe('executionScenarios[].seedDataTables', () => {
function caseWith(seedDataTables: unknown) {
return {
complexity: 'simple' as const,
tags: [],
conversation: [{ role: 'user' as const, text: 'do the thing' }],
executionScenarios: [
{
name: 'scenario-1',
description: 'seeded scenario',
dataSetup: 'a job application already exists',
successCriteria: 'the workflow reads it',
seedDataTables,
},
],
};
}
it('preserves a typed seed table with a string id column and a string-id row', () => {
const seedDataTables = [
{
id: 'job-applications-tbl',
name: 'Job Applications',
columns: [
{ name: 'id', type: 'string' as const },
{ name: 'is_active', type: 'boolean' as const },
],
rows: [{ id: 'row_001', is_active: true }],
},
];
const result = EvalTestCaseSchema.safeParse(caseWith(seedDataTables));
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.executionScenarios?.[0].seedDataTables).toEqual(seedDataTables);
});
it('is optional — a scenario without seed tables still parses', () => {
const result = EvalTestCaseSchema.safeParse({
complexity: 'simple' as const,
tags: [],
conversation: [{ role: 'user' as const, text: 'do the thing' }],
executionScenarios: [
{
name: 'scenario-1',
description: 'plain scenario',
dataSetup: 'nothing',
successCriteria: 'ok',
},
],
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.executionScenarios?.[0].seedDataTables).toBeUndefined();
});
it('rejects a too-short table id at load time (restore needs >=8 chars to remap)', () => {
const result = EvalTestCaseSchema.safeParse(
caseWith([
{
id: 'short',
name: 'Job Applications',
columns: [{ name: 'id', type: 'string' as const }],
},
]),
);
expect(result.success).toBe(false);
});
it('rejects an unknown column type', () => {
const result = EvalTestCaseSchema.safeParse(
caseWith([
{
id: 'job-applications-tbl',
name: 'Job Applications',
columns: [{ name: 'id', type: 'uuid' }],
},
]),
);
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,94 @@
{
"timestamp": "2026-07-17T00:00:00.000Z",
"duration": 1234,
"totalRuns": 2,
"experimentName": "exp-not-verified-fixture",
"summary": {
"testCases": 2,
"built": 2,
"scenariosTotal": 2,
"passAtK": 1,
"passHatK": 0.25,
"passRatePerIter": "100% / 0%",
"notVerified": 1
},
"comparisonStatus": "not_attempted",
"testCases": [
{
"name": "build it",
"testCaseFile": "behavioral-not-verified",
"status": "notVerified",
"buildSuccessCount": 2,
"totalRuns": 2,
"workflowChecksPerRun": [null, null],
"buildExpectationResultsPerRun": [null, null],
"buildExpectations": [],
"threadIds": [null, null],
"scenarios": [
{
"name": "happy-path",
"passCount": 0,
"evaluatedCount": 0,
"totalRuns": 2,
"passAtK": 0,
"passHatK": 0,
"runs": [
{
"workflowId": null,
"passed": false,
"incomplete": true,
"score": 0,
"reasoning": "nope",
"execErrors": []
},
{
"workflowId": null,
"passed": false,
"incomplete": true,
"score": 0,
"reasoning": "nope",
"execErrors": []
}
]
}
]
},
{
"name": "build it",
"testCaseFile": "scenario-verified",
"status": "verified",
"buildSuccessCount": 2,
"totalRuns": 2,
"workflowChecksPerRun": [null, null],
"buildExpectationResultsPerRun": [null, null],
"buildExpectations": [],
"threadIds": [null, null],
"scenarios": [
{
"name": "happy-path",
"passCount": 1,
"evaluatedCount": 2,
"totalRuns": 2,
"passAtK": 1,
"passHatK": 0.25,
"runs": [
{
"workflowId": null,
"passed": true,
"score": 1,
"reasoning": "ok",
"execErrors": []
},
{
"workflowId": null,
"passed": false,
"score": 0,
"reasoning": "nope",
"execErrors": []
}
]
}
]
}
]
}

View File

@ -0,0 +1,75 @@
{
"totalRuns": 1,
"summary": {
"testCases": 2,
"built": 1,
"notVerified": 0,
"scenariosTotal": 2,
"passAtK": 0.5,
"passHatK": 0.5,
"passRatePerIter": "50%"
},
"comparisonStatus": "not_attempted",
"testCases": [
{
"name": "build me something",
"status": "verified",
"buildSuccessCount": 1,
"totalRuns": 1,
"workflowChecksPerRun": [null],
"buildExpectationResultsPerRun": [null],
"buildExpectations": [],
"threadIds": [null],
"scenarios": [
{
"name": "happy path",
"passCount": 1,
"evaluatedCount": 1,
"totalRuns": 1,
"passAtK": 1,
"passHatK": 1,
"runs": [
{
"workflowId": "Wa",
"passed": true,
"score": 1,
"reasoning": "passed",
"execErrors": []
}
]
}
]
},
{
"name": "build me something",
"status": "verified",
"buildSuccessCount": 0,
"totalRuns": 1,
"workflowChecksPerRun": [null],
"buildExpectationResultsPerRun": [null],
"buildExpectations": [],
"threadIds": [null],
"scenarios": [
{
"name": "edge case",
"passCount": 0,
"evaluatedCount": 1,
"totalRuns": 1,
"passAtK": 0,
"passHatK": 0,
"runs": [
{
"workflowId": null,
"passed": false,
"score": 0,
"reasoning": "Scenario execution error: TimeoutError: The operation was aborted due to timeout",
"failureCategory": "framework_issue",
"rootCause": "Scenario execution exceeded its per-iteration time budget and was aborted before a verdict: TimeoutError: The operation was aborted due to timeout",
"execErrors": []
}
]
}
]
}
]
}

View File

@ -0,0 +1,124 @@
import { aggregateResults } from '../cli/aggregator';
import { abortedWorkflowTestCaseResult } from '../harness/runner';
import { classifyScenarioExecutionError } from '../harness/transient-error';
import type { ExecutionScenario, WorkflowTestCase, WorkflowTestCaseResult } from '../types';
// ---------------------------------------------------------------------------
// TRUST-310: a per-iteration budget / timeout abort must never lose the
// scenarios that already completed, and the offending scenario must be stamped
// with the pinned cross-repo contract so the lang-tracer side can route it to
// an infra bucket instead of counting it against product quality.
//
// - `failureCategory: "framework_issue"` (verbatim — do NOT invent a new one)
// - a `rootCause` string that mentions the timeout/budget
// ---------------------------------------------------------------------------
describe('classifyScenarioExecutionError', () => {
it('stamps a budget/timeout abort as framework_issue with a timeout rootCause', () => {
// undici's AbortSignal.timeout surfaces as this message via the n8n client.
const message = 'TimeoutError: The operation was aborted due to timeout';
const classified = classifyScenarioExecutionError(message);
expect(classified.failureCategory).toBe('framework_issue');
expect(classified.rootCause).toBeDefined();
expect(classified.rootCause?.toLowerCase()).toMatch(/timeout|budget/);
expect(classified.rootCause).toContain(message);
expect(classified.reasoning).toContain(message);
});
it('stamps a non-timeout framework error without a timeout rootCause', () => {
const message = 'fetch failed: ECONNRESET';
const classified = classifyScenarioExecutionError(message);
// Still framework_issue (any error escaping executeScenario is infra), but
// no timeout-flavoured rootCause — only budget aborts carry that.
expect(classified.failureCategory).toBe('framework_issue');
expect(classified.rootCause).toBeUndefined();
expect(classified.reasoning).toContain(message);
});
});
function makeTestCase(scenarios: ExecutionScenario[]): WorkflowTestCase {
return {
conversation: [{ role: 'user', text: 'build me something' }],
complexity: 'complex',
tags: ['test'],
executionScenarios: scenarios,
};
}
const scenarioA: ExecutionScenario = {
name: 'happy path',
description: 'a',
dataSetup: 'setup a',
successCriteria: 'criteria a',
};
const scenarioB: ExecutionScenario = {
name: 'edge case',
description: 'b',
dataSetup: 'setup b',
successCriteria: 'criteria b',
};
describe('abortedWorkflowTestCaseResult', () => {
it('produces a build-failed result with one framework_issue row per scenario', () => {
const testCase = makeTestCase([scenarioA, scenarioB]);
const message = 'TimeoutError: The operation was aborted due to timeout';
const result = abortedWorkflowTestCaseResult(testCase, 'http://localhost:5678', message);
expect(result.workflowBuildSuccess).toBe(false);
expect(result.buildError).toContain(message);
expect(result.n8nBaseUrl).toBe('http://localhost:5678');
// One result per declared scenario keeps the aggregator's positional index
// aligned so the case is counted rather than the whole batch being lost.
expect(result.executionScenarioResults).toHaveLength(2);
for (const sr of result.executionScenarioResults) {
expect(sr.success).toBe(false);
expect(sr.score).toBe(0);
expect(sr.failureCategory).toBe('framework_issue');
expect(sr.rootCause?.toLowerCase()).toMatch(/timeout|budget/);
}
});
});
describe('aggregateResults with a mid-run abort', () => {
it('keeps the completed case alongside an aborted case', () => {
// Case A completed before the abort: built + its scenario passed.
const caseA = makeTestCase([scenarioA]);
const completed: WorkflowTestCaseResult = {
testCase: caseA,
workflowBuildSuccess: true,
workflowId: 'Wa',
executionScenarioResults: [
{ scenario: scenarioA, success: true, score: 1, reasoning: 'passed' },
],
};
// Case B threw on a budget/timeout abort — captured instead of lost.
const caseB = makeTestCase([scenarioB]);
const aborted = abortedWorkflowTestCaseResult(
caseB,
'http://localhost:5678',
'TimeoutError: The operation was aborted due to timeout',
);
const evaluation = aggregateResults([[completed, aborted]], 1);
expect(evaluation.testCases).toHaveLength(2);
// The passing scenario survives the abort and is counted.
const aggA = evaluation.testCases[0];
expect(aggA.executionScenarios[0].passCount).toBe(1);
expect(aggA.executionScenarios[0].evaluatedCount).toBe(1);
// The aborted scenario is present and carries the pinned contract.
const aggB = evaluation.testCases[1];
const abortedRun = aggB.executionScenarios[0].runs[0];
expect(abortedRun.success).toBe(false);
expect(abortedRun.failureCategory).toBe('framework_issue');
expect(abortedRun.rootCause?.toLowerCase()).toMatch(/timeout|budget/);
});
});

View File

@ -0,0 +1,203 @@
import { vi } from 'vitest';
import type { Mock } from 'vitest';
import type { N8nClient } from '../clients/n8n-client';
import type { EvalLogger } from '../harness/logger';
import {
buildSeededTablesNote,
dedupeScenarioSeedTables,
reseedScenarioTables,
scenariosRequireSerialSeeding,
} from '../harness/runner';
import type { ExecutionScenario } from '../types';
// TRUST-311 follow-up: scenario data tables are created EMPTY before the build
// turn (so the agent discovers the real table and binds its real id), then row-
// seeded per scenario just before that scenario executes (so build-time row
// mutations don't leak across scenarios, and scenarios can carry different rows).
// These unit the pure pieces; the pre-build/per-scenario wiring is integration.
const silentLogger: EvalLogger = {
info: () => {},
verbose: () => {},
success: () => {},
warn: () => {},
error: () => {},
isVerbose: false,
};
function scenario(overrides: Partial<ExecutionScenario> = {}): ExecutionScenario {
return {
name: 'scenario',
description: 'd',
dataSetup: 'setup',
successCriteria: 'ok',
...overrides,
};
}
const jobApplications = {
id: 'job-applications-1234',
name: 'Job Applications',
columns: [{ name: 'application_id', type: 'string' as const }],
rows: [{ application_id: 'row_001' }],
};
describe('dedupeScenarioSeedTables', () => {
it('returns the union of scenario seed tables deduped by name (first wins)', () => {
const dup = {
...jobApplications,
id: 'applications-2222',
rows: [{ application_id: 'row_002' }],
};
const tables = dedupeScenarioSeedTables(
[scenario({ seedDataTables: [jobApplications] }), scenario({ seedDataTables: [dup] })],
silentLogger,
);
expect(tables).toEqual([jobApplications]); // first declaration wins
});
it('returns an empty array when no scenario declares a seed table', () => {
expect(
dedupeScenarioSeedTables([scenario(), scenario({ seedDataTables: [] })], silentLogger),
).toEqual([]);
});
it('warns when a later scenario redeclares the same name with a DIFFERENT shape', () => {
const warn = vi.fn();
const logger = { ...silentLogger, warn };
const conflicting = {
...jobApplications,
id: 'applications-2222',
rows: [{ application_id: 'row_002' }],
};
dedupeScenarioSeedTables(
[
scenario({ seedDataTables: [jobApplications] }),
scenario({ seedDataTables: [conflicting] }),
],
logger,
);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Job Applications'));
});
it('does not warn when the same name is redeclared identically', () => {
const warn = vi.fn();
const logger = { ...silentLogger, warn };
dedupeScenarioSeedTables(
[
scenario({ seedDataTables: [jobApplications] }),
scenario({ seedDataTables: [{ ...jobApplications }] }),
],
logger,
);
expect(warn).not.toHaveBeenCalled();
});
it('throws when the deduped union exceeds the 20-table cap', () => {
const tables = Array.from({ length: 21 }, (_, i) => ({
id: `table-id-${String(i).padStart(4, '0')}`,
name: `Table ${String(i)}`,
columns: [{ name: 'application_id', type: 'string' as const }],
}));
expect(() =>
dedupeScenarioSeedTables([scenario({ seedDataTables: tables })], silentLogger),
).toThrow(/20/);
});
});
describe('buildSeededTablesNote', () => {
it('is empty when there are no tables', () => {
expect(buildSeededTablesNote([])).toBe('');
});
it('names each table and its columns so the agent binds the real table', () => {
const note = buildSeededTablesNote([jobApplications]);
expect(note).toContain('Job Applications');
expect(note).toContain('application_id');
expect(note).toContain('string');
});
});
describe('scenariosRequireSerialSeeding', () => {
it('is true when any scenario declares seed tables', () => {
expect(
scenariosRequireSerialSeeding([scenario(), scenario({ seedDataTables: [jobApplications] })]),
).toBe(true);
});
it('is false when no scenario declares seed tables', () => {
expect(scenariosRequireSerialSeeding([scenario(), scenario({ seedDataTables: [] })])).toBe(
false,
);
});
});
function makeClient(seedDataTableRows: Mock): N8nClient {
return { seedDataTableRows } as unknown as N8nClient;
}
describe('reseedScenarioTables', () => {
it('clears + seeds each declared table by its bound real id', async () => {
const seedDataTableRows = vi.fn().mockResolvedValue(undefined);
const client = makeClient(seedDataTableRows);
await reseedScenarioTables(
client,
scenario({ seedDataTables: [jobApplications] }),
'thread-1',
{ 'Job Applications': 'dt-real-1' },
silentLogger,
);
expect(seedDataTableRows).toHaveBeenCalledWith('thread-1', 'dt-real-1', jobApplications.rows);
});
it('seeds an empty row set when a table declares no rows', async () => {
const seedDataTableRows = vi.fn().mockResolvedValue(undefined);
const client = makeClient(seedDataTableRows);
const noRows = { ...jobApplications, rows: undefined };
await reseedScenarioTables(
client,
scenario({ seedDataTables: [noRows] }),
'thread-1',
{ 'Job Applications': 'dt-real-1' },
silentLogger,
);
expect(seedDataTableRows).toHaveBeenCalledWith('thread-1', 'dt-real-1', []);
});
it('does nothing when the scenario declares no seed tables', async () => {
const seedDataTableRows = vi.fn();
const client = makeClient(seedDataTableRows);
await reseedScenarioTables(client, scenario(), 'thread-1', {}, silentLogger);
expect(seedDataTableRows).not.toHaveBeenCalled();
});
it('throws when a declared table was not pre-seeded (missing from the id map)', async () => {
const seedDataTableRows = vi.fn();
const client = makeClient(seedDataTableRows);
await expect(
reseedScenarioTables(
client,
scenario({ seedDataTables: [jobApplications] }),
'thread-1',
{}, // Job Applications not in the map
silentLogger,
),
).rejects.toThrow(/Job Applications/);
expect(seedDataTableRows).not.toHaveBeenCalled();
});
});

View File

@ -1,7 +1,74 @@
import { getCaseRunStatus, getCheckedRunCount, getRunScoredCounts } from '../summary';
import type { TestCaseAggregation, WorkflowTestCaseResult } from '../types';
import {
getCaseRunStatus,
getCheckedRunCount,
getRunScoredCounts,
rollupCaseVerification,
} from '../summary';
import type {
ExecutionScenarioAggregation,
TestCaseAggregation,
WorkflowTestCaseResult,
} from '../types';
import { baseTestCase } from './fixtures';
describe('rollupCaseVerification', () => {
function scenarioAgg(
overrides: Partial<ExecutionScenarioAggregation> = {},
): ExecutionScenarioAggregation {
return {
scenario: { name: 's', description: '', dataSetup: '', successCriteria: 'ok' },
runs: [],
evaluatedCount: 1,
passCount: 1,
passRate: 1,
passAtK: [1],
passHatK: [1],
...overrides,
};
}
function caseAgg(overrides: Partial<TestCaseAggregation>): TestCaseAggregation {
return {
testCase: baseTestCase(),
runs: [],
buildSuccessCount: 1,
executionScenarios: [],
buildExpectations: [],
status: 'verified',
...overrides,
};
}
it('counts a not-verified case separately from passed and failed', () => {
const cases: TestCaseAggregation[] = [
caseAgg({ status: 'verified', executionScenarios: [scenarioAgg({ passCount: 1 })] }),
caseAgg({
status: 'verified',
executionScenarios: [scenarioAgg({ passCount: 0, passRate: 0 })],
}),
caseAgg({
status: 'notVerified',
executionScenarios: [scenarioAgg({ evaluatedCount: 0, passCount: 0, passRate: 0 })],
}),
];
expect(rollupCaseVerification(cases)).toEqual({ passed: 1, failed: 1, notVerified: 1 });
});
it('does not count a not-verified case as passed', () => {
const cases: TestCaseAggregation[] = [
caseAgg({
status: 'notVerified',
executionScenarios: [scenarioAgg({ evaluatedCount: 0, passCount: 0, passRate: 0 })],
}),
];
const rollup = rollupCaseVerification(cases);
expect(rollup.passed).toBe(0);
expect(rollup.notVerified).toBe(1);
});
});
// A scenario-less case (e.g. an agent build that saves its artifact outside the workflow path)
// legitimately produces no workflow (workflowBuildSuccess: false). These lock in that such a run
// is scored on its outcome expectations — which cover the rendered agent/config-eval context —

View File

@ -0,0 +1,119 @@
import { mkdtempSync, readFileSync } from 'fs';
import { jsonParse } from 'n8n-workflow';
import { tmpdir } from 'os';
import { join } from 'path';
import { aggregateResults } from '../cli/aggregator';
import { writeEvalResults } from '../cli/index';
import type { ExecutionScenario, WorkflowTestCase, WorkflowTestCaseResult } from '../types';
// The lang-tracer dispatcher reads `eval-results.json`, not the in-memory
// aggregation. These tests pin that the `notVerified` status actually survives
// serialization: per-case `testCases[].status` and top-level `summary.notVerified`.
const scenario: ExecutionScenario = {
name: 'happy-path',
description: 'baseline',
dataSetup: 'plain',
successCriteria: 'works',
};
function scenarioCase(): WorkflowTestCase {
return {
conversation: [{ role: 'user', text: 'build it' }],
complexity: 'simple',
tags: [],
executionScenarios: [scenario],
datasets: ['full'],
};
}
function runResult(
testCase: WorkflowTestCase,
run: { success: boolean; incomplete?: boolean },
): WorkflowTestCaseResult {
return {
testCase,
workflowBuildSuccess: true,
executionScenarioResults: [
{
scenario,
success: run.success,
score: run.success ? 1 : 0,
reasoning: run.success ? 'ok' : 'nope',
...(run.incomplete ? { incomplete: true } : {}),
},
],
};
}
interface SerializedResults {
summary: { notVerified: number };
testCases: Array<{ status: string; testCaseFile?: string }>;
}
function writeAndRead(): SerializedResults {
const notVerifiedCase = scenarioCase();
const verifiedCase = scenarioCase();
// Two iterations. Case 0: every run incomplete → notVerified.
// Case 1: one pass, one fail → verified.
const evaluation = aggregateResults(
[
[
runResult(notVerifiedCase, { success: false, incomplete: true }),
runResult(verifiedCase, { success: true }),
],
[
runResult(notVerifiedCase, { success: false, incomplete: true }),
runResult(verifiedCase, { success: false }),
],
],
2,
);
const slugByTestCase = new Map<WorkflowTestCase, string>([
[notVerifiedCase, 'behavioral-not-verified'],
[verifiedCase, 'scenario-verified'],
]);
const dir = mkdtempSync(join(tmpdir(), 'eval-results-test-'));
const { jsonPath } = writeEvalResults(
evaluation,
1234,
dir,
'exp-test',
undefined,
undefined,
slugByTestCase,
undefined,
undefined,
);
return jsonParse<SerializedResults>(readFileSync(jsonPath, 'utf8'));
}
describe('writeEvalResults — notVerified serialization', () => {
it('serializes per-case status and the top-level notVerified count', () => {
const report = writeAndRead();
const byFile = new Map(report.testCases.map((tc) => [tc.testCaseFile, tc.status]));
expect(byFile.get('behavioral-not-verified')).toBe('notVerified');
expect(byFile.get('scenario-verified')).toBe('verified');
expect(report.summary.notVerified).toBe(1);
});
// The committed fixture is the cross-repo contract anchor consumed by the
// lang-tracer dispatcher test. Guard it so the pinned fields can't silently drift.
it('matches the committed golden fixture on the pinned fields', () => {
const golden = jsonParse<SerializedResults>(
readFileSync(join(__dirname, 'fixtures', 'eval-results.not-verified.json'), 'utf8'),
);
const live = writeAndRead();
expect(golden.summary.notVerified).toBe(live.summary.notVerified);
expect(golden.testCases.map((tc) => [tc.testCaseFile, tc.status])).toEqual(
live.testCases.map((tc) => [tc.testCaseFile, tc.status]),
);
});
});

View File

@ -0,0 +1,192 @@
import { jsonParse } from 'n8n-workflow';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runEvalAndPersist } from '../cli/index';
import type { EvalLogger } from '../harness/logger';
import { abortedWorkflowTestCaseResult } from '../harness/runner';
import type { ExecutionScenario, WorkflowTestCase, WorkflowTestCaseResult } from '../types';
// ---------------------------------------------------------------------------
// TRUST-310: the CLI must WRITE a partial eval-results.json when the run throws
// mid-flight (a per-iteration budget / timeout abort). Without a file the
// lang-tracer dispatcher discards the whole run — including scenarios that had
// already passed. This drives the actual write-on-abort seam to a real temp
// --output-dir and pins the JSON shape the dispatcher parses against a golden
// fixture (the cross-repo contract anchor).
// ---------------------------------------------------------------------------
/** Minimal shape of the emitted eval-results.json — only the fields these tests read. */
interface ParsedEvalResults {
timestamp?: string;
duration?: number;
testCases: Array<{
buildSuccessCount: number;
totalRuns: number;
scenarios: Array<{
passCount: number;
evaluatedCount: number;
totalRuns: number;
passAtK: number;
runs: Array<{
passed: boolean;
score: number;
failureCategory?: string;
rootCause?: string;
}>;
}>;
}>;
[key: string]: unknown;
}
const silentLogger: EvalLogger = {
info: () => {},
verbose: () => {},
success: () => {},
warn: () => {},
error: () => {},
isVerbose: false,
};
const scenarioA: ExecutionScenario = {
name: 'happy path',
description: 'a',
dataSetup: 'setup a',
successCriteria: 'criteria a',
};
const scenarioB: ExecutionScenario = {
name: 'edge case',
description: 'b',
dataSetup: 'setup b',
successCriteria: 'criteria b',
};
function makeTestCase(scenarios: ExecutionScenario[]): WorkflowTestCase {
return {
conversation: [{ role: 'user', text: 'build me something' }],
complexity: 'complex',
tags: ['test'],
executionScenarios: scenarios,
};
}
/** One iteration's results: case A completed + passed before the abort, case B
* was aborted by the budget/timeout. */
function partialIteration(): WorkflowTestCaseResult[] {
const completed: WorkflowTestCaseResult = {
testCase: makeTestCase([scenarioA]),
workflowBuildSuccess: true,
workflowId: 'Wa',
executionScenarioResults: [
{ scenario: scenarioA, success: true, score: 1, reasoning: 'passed' },
],
};
const aborted = abortedWorkflowTestCaseResult(
makeTestCase([scenarioB]),
'http://localhost:5678',
'TimeoutError: The operation was aborted due to timeout',
);
return [completed, aborted];
}
describe('runEvalAndPersist write-on-abort', () => {
let outputDir: string;
beforeEach(() => {
outputDir = mkdtempSync(join(tmpdir(), 'eval-310-'));
});
afterEach(() => {
rmSync(outputDir, { recursive: true, force: true });
});
it('writes eval-results.json with completed + aborted scenarios when the run throws', async () => {
// The run captures one completed iteration into the sink, then throws — the
// exact shape of a budget abort after some scenarios already passed.
const runEval = async (partialResults: WorkflowTestCaseResult[][]) => {
partialResults.push(partialIteration());
await Promise.resolve();
throw new Error('operation aborted due to timeout');
};
await expect(
runEvalAndPersist(
{
logger: silentLogger,
outputDir,
startTime: Date.now(),
iterations: 1,
tier: undefined,
commitSha: undefined,
rerun: undefined,
mcpBuildSpend: [],
},
runEval,
),
).rejects.toThrow(/aborted/);
const file = join(outputDir, 'eval-results.json');
expect(existsSync(file)).toBe(true);
const parsed = jsonParse<ParsedEvalResults>(readFileSync(file, 'utf8'));
// (a) the passing scenario that completed before the abort survives. The
// emitted per-scenario key is `scenarios`, and pass state is carried as
// passCount/evaluatedCount + passAtK/passHatK (NOT a `passRate` field).
const completedCase = parsed.testCases[0];
expect(completedCase.buildSuccessCount).toBe(1);
expect(completedCase.totalRuns).toBe(1);
expect(completedCase.scenarios[0].passCount).toBe(1);
expect(completedCase.scenarios[0].evaluatedCount).toBe(1);
expect(completedCase.scenarios[0].totalRuns).toBe(1);
expect(completedCase.scenarios[0].passAtK).toBe(1);
expect(completedCase.scenarios[0].runs[0].passed).toBe(true);
// (b) the aborted scenario carries the pinned cross-repo contract.
const abortedCase = parsed.testCases[1];
expect(abortedCase.buildSuccessCount).toBe(0);
expect(abortedCase.scenarios[0].passCount).toBe(0);
expect(abortedCase.scenarios[0].evaluatedCount).toBe(1);
const abortedRun = abortedCase.scenarios[0].runs[0];
expect(abortedRun.passed).toBe(false);
expect(abortedRun.score).toBe(0);
expect(abortedRun.failureCategory).toBe('framework_issue');
expect(abortedRun.rootCause).toContain('time budget');
});
it('matches the cross-repo golden fixture (minus the volatile timestamp/duration)', async () => {
const runEval = async (partialResults: WorkflowTestCaseResult[][]) => {
partialResults.push(partialIteration());
await Promise.resolve();
throw new Error('operation aborted due to timeout');
};
await expect(
runEvalAndPersist(
{
logger: silentLogger,
outputDir,
startTime: Date.now(),
iterations: 1,
tier: undefined,
commitSha: undefined,
rerun: undefined,
mcpBuildSpend: [],
},
runEval,
),
).rejects.toThrow(/aborted/);
const parsed = jsonParse<ParsedEvalResults>(
readFileSync(join(outputDir, 'eval-results.json'), 'utf8'),
);
// timestamp + duration are wall-clock and can't be pinned; the rest is the
// contract the dispatcher parses.
delete parsed.timestamp;
delete parsed.duration;
const golden = jsonParse<ParsedEvalResults>(
readFileSync(join(__dirname, 'fixtures', 'eval-results.partial-abort.json'), 'utf8'),
);
expect(parsed).toEqual(golden);
});
});

View File

@ -6,6 +6,7 @@ import type {
ExecutionScenarioAggregation,
BuildExpectationAggregation,
BuildExpectationResult,
CaseVerificationStatus,
} from '../types';
/**
@ -85,6 +86,23 @@ function aggregateUnit<T extends { pass: boolean; incomplete?: boolean }>(
};
}
/**
* A case is `notVerified` when nothing could be scored: every scenario and every
* build expectation came back with 0 evaluated runs (all incomplete, or skipped
* e.g. process expectations with no transcript). A build FAILURE is NOT a gap: the
* aggregator substitutes a non-incomplete "scenario not executed" result, so a
* build-failed case has evaluated (failing) scenarios and stays `verified`.
*/
function computeCaseStatus(
scenarios: ExecutionScenarioAggregation[],
expectations: BuildExpectationAggregation[],
): CaseVerificationStatus {
const evaluatedUnits =
scenarios.reduce((n, sa) => n + sa.evaluatedCount, 0) +
expectations.reduce((n, ea) => n + ea.evaluatedCount, 0);
return evaluatedUnits > 0 ? 'verified' : 'notVerified';
}
export function aggregateResults(
allRunResults: WorkflowTestCaseResult[][],
totalRuns: number,
@ -147,6 +165,7 @@ export function aggregateResults(
buildSuccessCount,
executionScenarios,
buildExpectations,
status: computeCaseStatus(executionScenarios, buildExpectations),
});
}

View File

@ -68,6 +68,7 @@ import {
type PrebuiltManifest,
} from '../harness/prebuilt-workflows';
import {
abortedWorkflowTestCaseResult,
buildWorkflow,
executeScenario,
cleanupBuild,
@ -79,6 +80,7 @@ import {
type BuildResult,
} from '../harness/runner';
import {
classifyScenarioExecutionError,
extractErrorMessage,
isTransientNetworkError,
MAX_EXEC_ATTEMPTS,
@ -93,6 +95,7 @@ import { seedMcpRegistry } from '../mcp-registry/seeder';
import { snapshotWorkflowIds } from '../outcome/workflow-discovery';
import { writeRunDebugReport } from '../report/run-debug-report';
import { writeWorkflowReport } from '../report/workflow-report';
import { rollupCaseVerification } from '../summary';
import type {
BuildExpectationResult,
MultiRunEvaluation,
@ -185,6 +188,10 @@ interface RunConfig {
* LangSmith experiment metadata and eval-results.json the run's only spend
* record beyond raw session logs, for a suite that's manual-only due to cost. */
mcpBuildSpend: McpBuildSpend[];
/** Optional sink the direct loop pushes each completed iteration's results into
* as they finish, so an abort that rejects the run still leaves the caller
* (runEvalAndPersist) with the scenarios that already completed. */
partialResults?: WorkflowTestCaseResult[][];
}
/** Map eval CLI args to the shared MCP builder's settings. */
@ -422,66 +429,67 @@ async function main(): Promise<void> {
const cleanupBuiltWorkflows =
args.deletePrebuiltWorkflows || (args.buildViaMcp && !args.keepWorkflows);
const mcpBuildSpend: McpBuildSpend[] = [];
const commitSha = process.env.LANGSMITH_REVISION_ID ?? process.env.GITHUB_SHA;
try {
const hasLangSmith = Boolean(process.env.LANGSMITH_API_KEY);
let evaluation: MultiRunEvaluation;
let experimentName: string | undefined;
let experimentUrl: string | undefined;
let outcome: ComparisonOutcome | undefined;
let slugByTestCase: Map<WorkflowTestCase, string> | undefined;
const mcpBuildSpend: McpBuildSpend[] = [];
// runEvalAndPersist owns the always-write guarantee: it writes
// eval-results.json even if the run below throws (a budget/timeout abort, a
// lane meltdown, an OOM), aggregating whatever completed scenarios were
// pushed into `partialResults` so the dispatcher never finds no file.
const { evaluation, slugByTestCase, outcome, gate, jsonPath, prCommentPath } =
await runEvalAndPersist(
{
logger,
outputDir: args.outputDir,
startTime,
iterations: args.iterations,
tier: args.tier,
commitSha,
rerun: ciRerunHint(),
mcpBuildSpend,
},
async (partialResults) => {
if (hasLangSmith) {
logger.info('LangSmith API key detected, using evaluate() with experiment tracking');
const langsmithRun = await runWithLangSmith({
args,
lanes,
logger,
testCasesWithFiles,
prebuiltManifest,
cleanupBuiltWorkflows,
mcpBuildLogDir,
mcpBuildSpend,
});
return {
evaluation: langsmithRun.evaluation,
experimentName: langsmithRun.experimentName,
experimentUrl: langsmithRun.experimentUrl,
outcome: langsmithRun.outcome,
slugByTestCase: langsmithRun.slugByTestCase,
};
}
logger.info(
'No LANGSMITH_API_KEY, running direct loop (results in eval-results.json only)',
);
const directRun = await runDirectLoop({
args,
lanes,
logger,
testCasesWithFiles,
prebuiltManifest,
cleanupBuiltWorkflows,
mcpBuildLogDir,
mcpBuildSpend,
partialResults,
});
return { evaluation: directRun.evaluation, slugByTestCase: directRun.slugByTestCase };
},
);
if (hasLangSmith) {
logger.info('LangSmith API key detected, using evaluate() with experiment tracking');
const langsmithRun = await runWithLangSmith({
args,
lanes,
logger,
testCasesWithFiles,
prebuiltManifest,
cleanupBuiltWorkflows,
mcpBuildLogDir,
mcpBuildSpend,
});
evaluation = langsmithRun.evaluation;
experimentName = langsmithRun.experimentName;
experimentUrl = langsmithRun.experimentUrl;
outcome = langsmithRun.outcome;
slugByTestCase = langsmithRun.slugByTestCase;
} else {
logger.info('No LANGSMITH_API_KEY, running direct loop (results in eval-results.json only)');
const directRun = await runDirectLoop({
args,
lanes,
logger,
testCasesWithFiles,
prebuiltManifest,
cleanupBuiltWorkflows,
mcpBuildLogDir,
mcpBuildSpend,
});
evaluation = directRun.evaluation;
slugByTestCase = directRun.slugByTestCase;
}
const totalDuration = Date.now() - startTime;
const commitSha = process.env.LANGSMITH_REVISION_ID ?? process.env.GITHUB_SHA;
// Gated tiers report an absolute green verdict in place of the baseline comparison.
const gate = isGatedTier(args.tier) ? evaluateGate(evaluation, { slugByTestCase }) : undefined;
const { jsonPath, prCommentPath } = writeEvalResults(
evaluation,
totalDuration,
args.outputDir,
experimentName,
outcome,
commitSha,
slugByTestCase,
ciRerunHint(),
gate,
mcpBuildSpend,
experimentUrl,
);
console.log(`Results: ${jsonPath}`);
console.log(`PR comment: ${prCommentPath}`);
const reportResults = flattenRunsForReport(evaluation);
@ -644,6 +652,7 @@ async function runWithLangSmith(config: RunConfig): Promise<{
seedFile: buildArgs.seedFile,
priorConversation: buildArgs.priorConversation,
seedThread: buildArgs.seedThread,
executionScenarios: buildArgs.executionScenarios,
createdCredentialIds: lane.createdCredentialIds,
timeoutMs: buildArgs.timeoutMs,
preRunWorkflowIds: lane.preRunWorkflowIds,
@ -987,6 +996,27 @@ async function runWithLangSmith(config: RunConfig): Promise<{
const iteration = inputs._iteration ?? 0;
try {
return await targetRow(inputs, iteration);
} catch (error: unknown) {
// targetRow guards scenario execution internally, but a build-phase throw
// (getOrBuild) or a budget abort must not reject up to evaluate() and
// abort the whole experiment — that would discard every OTHER row's
// completed results. Record this row as an aborted (framework_issue)
// output instead so evaluate() finalizes and writeEvalResults still runs.
const message = extractErrorMessage(error);
logger.error(` ERROR [${inputs.scenarioName}] (${inputs.testCaseFile}): ${message}`);
const classified = classifyScenarioExecutionError(message);
return {
buildSuccess: false,
passed: false,
score: 0,
reasoning: classified.reasoning,
failureCategory: classified.failureCategory,
rootCause: classified.rootCause,
execErrors: [message],
buildDurationMs: 0,
execDurationMs: 0,
nodeCount: 0,
};
} finally {
await releaseCaseRow(iteration, inputs.testCaseFile);
}
@ -1091,18 +1121,22 @@ async function runWithLangSmith(config: RunConfig): Promise<{
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
continue;
}
// Mirror direct mode's per-scenario guard — without this, n8n API errors
// or verifier timeouts from executeWithLlmMock / verifyChecklist would
// escape to LangSmith, come back as a Run with null outputs, and be
// misclassified as builder regressions by the feedback extractor.
// Mirror direct mode's per-scenario guard — without this, n8n API errors,
// verifier timeouts, or a per-iteration budget abort from
// executeWithLlmMock / verifyChecklist would escape to LangSmith, come
// back as a Run with null outputs, and be misclassified as builder
// regressions by the feedback extractor. classifyScenarioExecutionError
// stamps framework_issue + a timeout-flavoured rootCause for budget aborts.
logger.error(` ERROR [${scenario.name}]: ${errorMessage}`);
const classified = classifyScenarioExecutionError(errorMessage);
return await attachExpectations({
buildSuccess: true,
workflowId: build.workflowId,
passed: false,
score: 0,
reasoning: `Scenario execution error: ${errorMessage}`,
failureCategory: 'framework_issue',
reasoning: classified.reasoning,
failureCategory: classified.failureCategory,
rootCause: classified.rootCause,
execErrors: [errorMessage],
buildDurationMs,
execDurationMs: Date.now() - execStart,
@ -1558,29 +1592,40 @@ async function runDirectLoop(config: RunConfig): Promise<{
tc.fileSlug,
iter,
);
const result = await runWorkflowTestCase({
client: lane.client,
baseUrl: lane.baseUrl,
testCase: tc.testCase,
timeoutMs: args.timeoutMs,
createdCredentialIds: lane.createdCredentialIds,
preRunWorkflowIds: lane.preRunWorkflowIds,
claimedWorkflowIds: lane.claimedWorkflowIds,
logger,
keepWorkflows: args.keepWorkflows,
laneTag,
prebuiltWorkflowId,
pinAiRoots: args.pinAiRoots,
});
if (
prebuiltWorkflowId !== undefined &&
cleanupBuiltWorkflows &&
result.workflowBuildSuccess &&
result.workflowId
) {
lane.workflowIdsToDelete.add(result.workflowId);
try {
const result = await runWorkflowTestCase({
client: lane.client,
baseUrl: lane.baseUrl,
testCase: tc.testCase,
timeoutMs: args.timeoutMs,
createdCredentialIds: lane.createdCredentialIds,
preRunWorkflowIds: lane.preRunWorkflowIds,
claimedWorkflowIds: lane.claimedWorkflowIds,
logger,
keepWorkflows: args.keepWorkflows,
laneTag,
prebuiltWorkflowId,
pinAiRoots: args.pinAiRoots,
});
if (
prebuiltWorkflowId !== undefined &&
cleanupBuiltWorkflows &&
result.workflowBuildSuccess &&
result.workflowId
) {
lane.workflowIdsToDelete.add(result.workflowId);
}
return result;
} catch (error: unknown) {
// runWorkflowTestCase guards its own scenario loop, but a throw
// from the build phase (or a per-iteration budget abort) would
// otherwise reject runWithConcurrency and take every OTHER case's
// completed results down with it. Record this one case as an
// aborted (framework_issue) result and keep the batch alive.
const message = extractErrorMessage(error);
logger.error(` ERROR running ${tc.fileSlug}${laneTag}: ${message}`);
return abortedWorkflowTestCaseResult(tc.testCase, lane.baseUrl, message);
}
return result;
},
MAX_CONCURRENT_BUILDS,
);
@ -1589,7 +1634,12 @@ async function runDirectLoop(config: RunConfig): Promise<{
);
const flat = laneResults.flat();
flat.sort((a, b) => a.origIdx - b.origIdx);
return flat.map((x) => x.result);
const iterationResults = flat.map((x) => x.result);
// Capture each iteration as it completes (source-ordered, full-length) so
// an abort in a LATER iteration still leaves runEvalAndPersist the ones
// that finished — every captured array is a complete, index-aligned row.
config.partialResults?.push(iterationResults);
return iterationResults;
}),
);
@ -1720,7 +1770,104 @@ function ciRerunHint(): RerunHint | undefined {
};
}
function writeEvalResults(
/** What `runEval` produces on success the aggregation plus the LangSmith-only
* comparison metadata (undefined in direct-loop mode). */
export interface EvalRunOutput {
evaluation: MultiRunEvaluation;
experimentName?: string;
experimentUrl?: string;
outcome?: ComparisonOutcome;
slugByTestCase?: Map<WorkflowTestCase, string>;
}
export interface PersistEvalConfig {
logger: EvalLogger;
outputDir: string | undefined;
startTime: number;
iterations: number;
tier: CliArgs['tier'];
commitSha: string | undefined;
rerun: RerunHint | undefined;
mcpBuildSpend: McpBuildSpend[];
}
export interface PersistedEval extends EvalRunOutput {
gate: GateResult | undefined;
jsonPath: string;
prCommentPath: string;
}
/**
* Run the eval via `runEval` and ALWAYS persist eval-results.json +
* eval-pr-comment.md. On success returns the aggregation + write metadata so the
* caller can render the HTML report / terminal summary.
*
* If `runEval` throws a per-iteration budget/timeout abort, a lane meltdown, an
* OOM whatever it pushed into `partialResults` before throwing is aggregated
* and written before the error is re-thrown. Aggregation already tolerates
* missing/incomplete entries, so the scenarios that already completed survive
* instead of the dispatcher finding no file and discarding the entire run.
*/
export async function runEvalAndPersist(
config: PersistEvalConfig,
runEval: (partialResults: WorkflowTestCaseResult[][]) => Promise<EvalRunOutput>,
): Promise<PersistedEval> {
const partialResults: WorkflowTestCaseResult[][] = [];
let persisted = false;
try {
const out = await runEval(partialResults);
// Gated tiers report an absolute green verdict in place of the baseline comparison.
const gate = isGatedTier(config.tier)
? evaluateGate(out.evaluation, { slugByTestCase: out.slugByTestCase })
: undefined;
const { jsonPath, prCommentPath } = writeEvalResults(
out.evaluation,
Date.now() - config.startTime,
config.outputDir,
out.experimentName,
out.outcome,
config.commitSha,
out.slugByTestCase,
config.rerun,
gate,
config.mcpBuildSpend,
out.experimentUrl,
);
persisted = true;
return { ...out, gate, jsonPath, prCommentPath };
} finally {
if (!persisted) {
try {
const evaluation: MultiRunEvaluation =
partialResults.length > 0
? aggregateResults(partialResults, partialResults.length)
: { totalRuns: config.iterations, testCases: [] };
const { jsonPath } = writeEvalResults(
evaluation,
Date.now() - config.startTime,
config.outputDir,
undefined,
undefined,
config.commitSha,
undefined,
config.rerun,
undefined,
config.mcpBuildSpend,
undefined,
);
config.logger.error(
`Eval run did not finish cleanly — wrote partial results (${String(partialResults.length)} iteration(s)) to ${jsonPath}`,
);
} catch (writeError: unknown) {
config.logger.error(
`Failed to write partial eval results: ${extractErrorMessage(writeError)}`,
);
}
}
}
}
export function writeEvalResults(
evaluation: MultiRunEvaluation,
duration: number,
outputDir: string | undefined,
@ -1735,6 +1882,7 @@ function writeEvalResults(
): { jsonPath: string; prCommentPath: string } {
const { totalRuns, testCases } = evaluation;
const metrics = computeAggregateMetrics(evaluation);
const verification = rollupCaseVerification(testCases);
const result = outcome?.kind === 'ok' ? outcome.result : undefined;
@ -1756,6 +1904,9 @@ function writeEvalResults(
passAtK: metrics.passAtK,
passHatK: metrics.passHatK,
passRatePerIter: metrics.passRatePerIter,
// Cases where nothing could be scored (all units incomplete / skipped) —
// reported apart from the pass rate, never as a silent pass.
notVerified: verification.notVerified,
...(checksSummary ? { workflowChecks: checksSummary } : {}),
...(buildSpendSummary ? { mcpBuild: buildSpendSummary } : {}),
},
@ -1777,6 +1928,9 @@ function writeEvalResults(
testCases: testCases.map((tc) => ({
name: caseDisplayPrompt(tc.testCase, tc.runs[0]?.transcript).slice(0, 70),
testCaseFile: slugByTestCase?.get(tc.testCase),
// `notVerified` when no scenario or expectation was scored across runs —
// consumers must not treat a zero pass rate here as a pass.
status: tc.status,
buildSuccessCount: tc.buildSuccessCount,
totalRuns,
workflowChecksPerRun: tc.runs.map((run) =>
@ -1909,7 +2063,12 @@ async function tryRunComparison(config: {
}
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
// Only auto-run as the CLI entry point. Importing this module (e.g. from a unit
// test that exercises the exported runEvalAndPersist / writeEvalResults seams)
// must not kick off a real eval run against process.argv.
if (!process.env.VITEST) {
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
}

View File

@ -635,6 +635,13 @@ export class N8nClient {
* referenced workflows are recreated (node credentials stripped server-side)
* and the native message log is written verbatim, so the thread continues
* as if the conversation really happened.
*
* `uniquifyNames` (default true) appends a unique suffix to each seed data
* table's name to dodge the per-project unique-name constraint safe when the
* seed workflow references tables by id (id-remap). Pass false to keep the
* EXACT declared names, so a freshly-built workflow's by-name references
* resolve (TRUST-311 scenario seeding). `messages`/`workflows` may be empty to
* seed only data tables.
* POST /rest/instance-ai/eval/restore-thread
*/
async restoreThread(
@ -642,14 +649,37 @@ export class N8nClient {
messages: Array<Record<string, unknown>>,
workflows: InstanceAiEvalSeedWorkflow[],
dataTables: InstanceAiEvalSeedDataTable[] = [],
options: { uniquifyNames?: boolean } = {},
): Promise<{ restored: number; workflowIds: string[]; dataTableIds: string[] }> {
const body: Record<string, unknown> = { threadId, messages, workflows, dataTables };
if (options.uniquifyNames !== undefined) body.uniquifyNames = options.uniquifyNames;
const result = await this.fetch('/rest/instance-ai/eval/restore-thread', {
method: 'POST',
body: { threadId, messages, workflows, dataTables },
body,
});
return RestoreThreadEnvelope.parse(result).data;
}
/**
* Reset an existing data table's rows to exactly `rows` (clear-then-insert),
* for the per-scenario row seeding of a case that pre-created its tables
* before the build turn (TRUST-311). Unlike `restoreThread` (which CREATES
* tables), this targets a table that already exists by id, so a scenario can
* declare its own row state without disturbing the table the built workflow
* bound. `threadId` scopes the table to the run's project server-side.
* POST /rest/instance-ai/eval/seed-data-table-rows
*/
async seedDataTableRows(
threadId: string,
tableId: string,
rows: Array<Record<string, string | number | boolean | null>>,
): Promise<void> {
await this.fetch('/rest/instance-ai/eval/seed-data-table-rows', {
method: 'POST',
body: { threadId, tableId, rows },
});
}
// -- Data tables ---------------------------------------------------------
/**

View File

@ -6,7 +6,11 @@
// LLM-mocked HTTP, checklist verification, and result aggregation.
// ---------------------------------------------------------------------------
import type { InstanceAiConfirmRequest, InstanceAiEvalExecutionResult } from '@n8n/api-types';
import type {
InstanceAiConfirmRequest,
InstanceAiEvalExecutionResult,
InstanceAiEvalSeedDataTable,
} from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import crypto from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
@ -34,6 +38,7 @@ import { reconstructSeedFromThread, type SeedThreadRef } from './langsmith-seed'
import { type EvalLogger } from './logger';
import { fetchPrebuiltBuild } from './prebuilt-workflows';
import {
classifyScenarioExecutionError,
extractErrorMessage,
isTransientExecutionAbort,
MAX_EXEC_ATTEMPTS,
@ -264,6 +269,35 @@ interface WorkflowTestCaseConfig {
pinAiRoots?: string[];
}
/**
* Synthetic result for a test case whose run threw before it could produce one
* (a budget/timeout abort, a lane meltdown, an OOM). Recording it instead of
* letting the throw reject the batch keeps every OTHER case's already-completed
* results, and keeps this case index-aligned so the aggregator counts it rather
* than losing the whole run. One `framework_issue` row per declared scenario
* carries the pinned cross-repo contract (timeout-flavoured rootCause for budget
* aborts) so the lang-tracer side buckets it as infra, not product quality.
*/
export function abortedWorkflowTestCaseResult(
testCase: WorkflowTestCase,
baseUrl: string,
errorMessage: string,
): WorkflowTestCaseResult {
const classified = classifyScenarioExecutionError(errorMessage);
return {
testCase,
workflowBuildSuccess: false,
buildError: errorMessage,
n8nBaseUrl: baseUrl,
executionScenarioResults: (testCase.executionScenarios ?? []).map((scenario) => ({
scenario,
success: false,
score: 0,
...classified,
})),
};
}
/**
* All-in-one test case runner: build workflow + run all scenarios + cleanup.
* Used by the CLI. The split API (buildWorkflow + executeScenario + cleanupBuild)
@ -297,6 +331,7 @@ export async function runWorkflowTestCase(
seedFile: testCase.seedFile,
priorConversation: testCase.priorConversation,
seedThread: testCase.seedThread,
executionScenarios: testCase.executionScenarios,
createdCredentialIds: config.createdCredentialIds,
timeoutMs,
preRunWorkflowIds: config.preRunWorkflowIds,
@ -414,9 +449,21 @@ export async function runWorkflowTestCase(
result.buildTrace = build.buildTrace;
const testCaseArtifactName = deriveTestCaseArtifactName(testCase);
const scenarios = testCase.executionScenarios ?? [];
// Rows for a case's pre-seeded scenario tables are swapped in per scenario
// (TRUST-311). All scenarios of a case share one table per name, so seeding
// must run serially — concurrent scenarios would race on the shared rows.
const seedContext =
build.seededScenarioTableIdsByName && build.threadId
? { threadId: build.threadId, tableIdsByName: build.seededScenarioTableIdsByName }
: undefined;
const scenarioConcurrency = scenariosRequireSerialSeeding(scenarios)
? 1
: MAX_CONCURRENT_SCENARIOS;
const scenarioStart = Date.now();
const scenariosPromise = runWithConcurrency(
testCase.executionScenarios ?? [],
scenarios,
async (scenario) => {
for (let attempt = 1; ; attempt++) {
try {
@ -430,6 +477,7 @@ export async function runWorkflowTestCase(
testCaseArtifactName,
build.buildTrace,
config.pinAiRoots,
seedContext,
);
} catch (error: unknown) {
const errorMessage = extractErrorMessage(error);
@ -442,21 +490,23 @@ export async function runWorkflowTestCase(
}
// executeScenario categorizes builder/mock/verification failures
// internally; an error escaping it is an infra/framework problem
// (network drop, n8n API error, verifier timeout). Tag it
// framework_issue so the report and baseline keep it out of builder
// regressions instead of scoring it as an uncategorized failure.
// (network drop, n8n API error, verifier timeout, per-iteration
// budget abort). Tag it framework_issue — with a timeout-flavoured
// rootCause when it's a budget abort — so the report and baseline
// keep it out of builder regressions instead of scoring it as an
// uncategorized failure, and this one timed-out scenario doesn't
// take the whole case's already-completed scenarios down with it.
logger.error(` ERROR [${scenario.name}]: ${errorMessage}`);
return {
scenario,
success: false,
score: 0,
reasoning: `Scenario execution error: ${errorMessage}`,
failureCategory: 'framework_issue',
...classifyScenarioExecutionError(errorMessage),
} satisfies ExecutionScenarioResult;
}
}
},
MAX_CONCURRENT_SCENARIOS,
scenarioConcurrency,
);
const [scenarioResults, expectationResults] = await Promise.all([
@ -494,6 +544,9 @@ interface MultiTurnDriverConfig {
logger: EvalLogger;
proxyResponses?: Map<string, InstanceAiConfirmRequest>;
followUpMessagesOut?: string[];
/** Appended to the FIRST sent message only (pre-seeded-table hint); the
* recorded turn and the proxy's conversation keep the clean prompt. */
openingMessageSuffix?: string;
}
async function driveMultiTurnConversation(
@ -519,7 +572,10 @@ async function driveMultiTurnConversation(
};
recordUserTurn(config.events, openingMessage);
await config.client.sendMessage(config.threadId, openingMessage);
await config.client.sendMessage(
config.threadId,
openingMessage + (config.openingMessageSuffix ?? ''),
);
await runMultiTurnConversation({
client: config.client,
@ -550,6 +606,11 @@ export interface BuildResult {
/** IDs to pass to cleanupBuild() */
createdWorkflowIds: string[];
createdDataTableIds: string[];
/** Maps each scenario seed table's declared NAME to the real id it was created
* under (empty) before the build turn, so each scenario can reset+seed its
* rows into the table the built workflow actually bound (TRUST-311 follow-up).
* Absent when the case declares no scenario seed tables. */
seededScenarioTableIdsByName?: Record<string, string>;
/** Non-workflow artifact refs (agent, config-eval) captured from the SSE stream,
* fed to the build-expectations judge context. Empty/undefined for prebuilt runs. */
artifactRefs?: ArtifactRef[];
@ -593,6 +654,9 @@ export interface BuildWorkflowConfig {
/** Reproduce a real conversation from its LangSmith trace (seed = before the
* last user message, live = that message). */
seedThread?: SeedThreadRef;
/** Execution scenarios whose declared `seedDataTables` are created + row-seeded
* after a successful build, before any scenario runs (TRUST-311). */
executionScenarios?: ExecutionScenario[];
timeoutMs?: number;
preRunWorkflowIds: Set<string>;
claimedWorkflowIds: Set<string>;
@ -649,6 +713,17 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
let credentialViewPinned = true;
let restoredWorkflowIds: string[] = [];
let restoredDataTableIds: string[] = [];
// TRUST-311 follow-up: scenario seed tables are created empty before the build
// turn (so the agent binds their real id); this maps declared name → real id
// for the per-scenario row seeding, and the note tells the agent they exist.
const scenarioTableIdsByName: Record<string, string> = {};
let scenarioSeedTablesNote = '';
// Ids the build itself produced (the agent's workflow + any data tables it
// made). Tracked here so a throw AFTER the build lands — scenario-table
// seeding, workflow checks — still hands them to the caller's cleanup rather
// than leaking them into the shared eval project.
let builtWorkflowIds: string[] = [];
let builtDataTableIds: string[] = [];
let seededTranscript: TranscriptTurn[] = [];
let seedingFailed = false;
@ -755,6 +830,43 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
}
}
// TRUST-311 follow-up: create the case's execution-scenario data tables EMPTY
// under their EXACT declared names BEFORE the build turn, so the agent
// discovers the real table (Data Table list/schema) and binds its real id —
// the production-faithful flow where the user's table pre-exists. Rows are
// reset+seeded per scenario (reseedScenarioTables) because a build-time
// self-verification execution can mutate them. The created ids fold into
// restoredDataTableIds so the outer catch and cleanupBuild already cover them
// (a build failure still cleans them up); a create failure is a harness
// problem, so flag seedingFailed → the CLI attributes framework_issue.
try {
const scenarioSeedTables = dedupeScenarioSeedTables(config.executionScenarios ?? [], logger);
if (scenarioSeedTables.length > 0) {
const schemasOnly = scenarioSeedTables.map((table) => ({ ...table, rows: undefined }));
const { dataTableIds } = await client.restoreThread(threadId, [], [], schemasOnly, {
uniquifyNames: false,
});
// restoreThread returns ids in input order; a length mismatch means we
// can't safely map names to ids, so fail rather than mis-seed.
if (dataTableIds.length !== scenarioSeedTables.length) {
throw new Error(
`Pre-seeding created ${String(dataTableIds.length)} data table(s) but the case declares ${String(scenarioSeedTables.length)}; cannot map names to ids.`,
);
}
scenarioSeedTables.forEach((table, index) => {
scenarioTableIdsByName[table.name] = dataTableIds[index];
});
restoredDataTableIds = [...restoredDataTableIds, ...dataTableIds];
scenarioSeedTablesNote = buildSeededTablesNote(scenarioSeedTables);
logger.info(
` Pre-seeded ${String(dataTableIds.length)} scenario data table schema(s)${config.laneTag ?? ''}`,
);
}
} catch (error: unknown) {
seedingFailed = true;
throw error;
}
const ssePromise = startSseConnection(client, threadId, events, abortController.signal).catch(
() => {},
);
@ -775,10 +887,13 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
logger,
proxyResponses,
followUpMessagesOut: followUpMessages,
// The pre-seeded-table note goes to the agent, but the recorded turn
// (and the graded transcript) keeps the clean user prompt.
openingMessageSuffix: scenarioSeedTablesNote,
});
} else {
recordUserTurn(events, openingMessage);
await client.sendMessage(threadId, openingMessage);
await client.sendMessage(threadId, openingMessage + scenarioSeedTablesNote);
await waitForAllActivity({
client,
threadId,
@ -835,6 +950,8 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
config.claimedWorkflowIds,
{ allowListDiffFallback: config.allowWorkflowListDiffFallback === true, logger },
);
builtWorkflowIds = outcome.workflowsCreated.map((wf) => wf.id);
builtDataTableIds = outcome.dataTablesCreated;
if (outcome.workflowsCreated.length === 0) {
// Answer-only cases (no execution scenarios, no outcome expectations)
@ -893,6 +1010,9 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
logger,
});
// The case's scenario data tables were created empty before the build turn
// (see the pre-build block above), so the agent bound their real ids; their
// per-scenario rows are seeded in runScenario via seededScenarioTableIdsByName.
return {
success: true,
workflowId: outcome.workflowsCreated[0].id,
@ -900,6 +1020,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
buildTrace,
createdWorkflowIds: outcome.workflowsCreated.map((wf) => wf.id),
createdDataTableIds: [...outcome.dataTablesCreated, ...restoredDataTableIds],
seededScenarioTableIdsByName: scenarioTableIdsByName,
artifactRefs: eventOutcome.artifactRefs,
conversationMetrics,
events,
@ -917,8 +1038,8 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
success: false,
error: error instanceof Error ? error.message : String(error),
workflowJsons: [],
createdWorkflowIds: restoredWorkflowIds,
createdDataTableIds: restoredDataTableIds,
createdWorkflowIds: [...restoredWorkflowIds, ...builtWorkflowIds],
createdDataTableIds: [...restoredDataTableIds, ...builtDataTableIds],
conversationMetrics,
events,
threadId,
@ -948,6 +1069,7 @@ export async function executeScenario(
testCaseName?: string,
buildTrace?: BuildTrace,
pinAiRoots?: string[],
seedContext?: ScenarioSeedContext,
): Promise<ExecutionScenarioResult> {
return await runScenario(
client,
@ -959,6 +1081,129 @@ export async function executeScenario(
testCaseName,
buildTrace,
pinAiRoots,
seedContext,
);
}
/** Per-scenario row-seeding context: the run's thread and the namereal-id map
* of the tables created empty before the build turn (TRUST-311 follow-up). */
export interface ScenarioSeedContext {
threadId: string;
tableIdsByName: Record<string, string>;
}
/** Max distinct scenario seed tables per case mirrors the restore-thread
* DTO's `dataTables` cap, since the whole union is sent in one call. */
const MAX_SEED_DATA_TABLES = 20;
/**
* Deduplicate the data tables an execution-scenario case declares
* (`seedDataTables`) into the union a case shares across its scenarios
* (TRUST-311). A table name is unique per project and the built workflow binds
* it by name, so a case shares ONE table per name across its scenarios; the
* first declaration wins. A later same-name declaration with a different shape
* (columns/rows) is dropped with a warning the by-name binding can only
* resolve to one table, so keeping the first silently would be data loss for the
* author. Throws if the distinct-name union exceeds the restore-thread DTO's cap
* (the whole union is created in one call). The returned tables carry their
* declared `rows`, but the pre-build creation seeds only the schema rows are
* reset+seeded per scenario (`reseedScenarioTables`).
*/
export function dedupeScenarioSeedTables(
scenarios: ExecutionScenario[],
logger: EvalLogger,
): InstanceAiEvalSeedDataTable[] {
const byName = new Map<string, InstanceAiEvalSeedDataTable>();
for (const scenario of scenarios) {
for (const table of scenario.seedDataTables ?? []) {
const existing = byName.get(table.name);
if (existing) {
if (!sameSeedTableShape(existing, table)) {
logger.warn(
` Scenario seed table "${table.name}" is declared more than once with different columns/rows; keeping the first declaration and ignoring the rest.`,
);
}
continue;
}
byName.set(table.name, table);
}
}
if (byName.size > MAX_SEED_DATA_TABLES) {
throw new Error(
`A case declares ${String(byName.size)} distinct scenario seed data tables, exceeding the ${String(MAX_SEED_DATA_TABLES)}-table restore limit; reduce the number of distinct table names.`,
);
}
return [...byName.values()];
}
/**
* A note appended to the build's opening message naming the data tables that
* already exist in the workspace (created empty before the build turn) so the
* agent discovers and binds the REAL table (via the Data Table node's
* list/schema) instead of creating a duplicate the production-faithful flow
* where the user's table pre-exists (TRUST-311 follow-up). Empty when the case
* declares no scenario seed tables.
*/
export function buildSeededTablesNote(tables: InstanceAiEvalSeedDataTable[]): string {
if (tables.length === 0) return '';
const lines = tables.map((table) => {
const columns = table.columns.map((column) => `${column.name}: ${column.type}`).join(', ');
return `- "${table.name}" (columns: ${columns})`;
});
return `\n\nThe following data table(s) already exist in this workspace — reuse them (look them up with the Data Table node's list/schema) instead of creating new ones:\n${lines.join('\n')}`;
}
/**
* True when any scenario declares seed tables. All of a case's scenarios share
* one table per name, so their per-scenario row reset+seed
* (`reseedScenarioTables`) must run serially concurrent scenarios would race
* on the shared table's rows. Callers gate scenario concurrency to 1 for such
* cases.
*/
export function scenariosRequireSerialSeeding(scenarios: ExecutionScenario[]): boolean {
return scenarios.some((scenario) => (scenario.seedDataTables?.length ?? 0) > 0);
}
/**
* Reset + row-seed a scenario's declared data tables into their pre-seeded real
* ids, just before that scenario executes (TRUST-311). Clears whatever rows a
* prior scenario or a build-time self-verification execution left, then
* inserts this scenario's declared rows, so each scenario runs against exactly
* the state it declared (and scenarios may carry different rows for the same
* table). `tableIdsByName` maps the declared table name to the real id created
* before the build turn; a name missing from it means the table was never
* pre-seeded, which is a harness bug, so throw rather than silently skip.
*/
export async function reseedScenarioTables(
client: N8nClient,
scenario: ExecutionScenario,
threadId: string,
tableIdsByName: Record<string, string>,
logger: EvalLogger,
): Promise<void> {
for (const table of scenario.seedDataTables ?? []) {
const tableId = tableIdsByName[table.name];
if (!tableId) {
throw new Error(
`Scenario "${scenario.name}" declares seed table "${table.name}" that was not pre-seeded before the build; cannot bind its rows.`,
);
}
await client.seedDataTableRows(threadId, tableId, table.rows ?? []);
logger.verbose(
` [${scenario.name}] reseeded data table "${table.name}" (${String((table.rows ?? []).length)} row(s))`,
);
}
}
/** Two seed tables bind the same way iff their columns + rows match (the id
* differs per declaration and is cosmetic under by-name seeding). */
function sameSeedTableShape(
a: InstanceAiEvalSeedDataTable,
b: InstanceAiEvalSeedDataTable,
): boolean {
return (
JSON.stringify({ columns: a.columns, rows: a.rows }) ===
JSON.stringify({ columns: b.columns, rows: b.rows })
);
}
@ -1141,10 +1386,26 @@ async function runScenario(
testCaseName?: string,
buildTrace?: BuildTrace,
pinAiRoots?: string[],
seedContext?: ScenarioSeedContext,
): Promise<ExecutionScenarioResult> {
const pinNodes = pinAiRoots && pinAiRoots.length > 0 ? pinAiRoots : undefined;
const targetWorkflowId = selectScenarioWorkflowId(scenario, workflowId, workflowJsons, logger);
// Reset + seed this scenario's declared rows into the tables the build bound,
// just before it runs — clears any prior scenario's (or build-time) rows so
// each scenario runs against exactly the state it declared (TRUST-311). Runs
// serially per case (see scenariosRequireSerialSeeding) to avoid racing on the
// shared table.
if (seedContext) {
await reseedScenarioTables(
client,
scenario,
seedContext.threadId,
seedContext.tableIdsByName,
logger,
);
}
const execStart = Date.now();
let evalResult = await client.executeWithLlmMock(
targetWorkflowId,

View File

@ -1,3 +1,4 @@
import { instanceAiEvalSeedDataTableSchema } from '@n8n/api-types';
import { z } from 'zod';
import { SUPPORTED_CREDENTIAL_TYPES } from '../credentials/seeder';
@ -26,6 +27,12 @@ const ExecutionScenarioSchema = z.object({
dataSetup: z.string(),
successCriteria: z.string(),
requires: z.string().optional(),
/** Typed data tables to seed before this scenario executes (TRUST-311).
* Unlike free-text `dataSetup`, this declares each column's type, so a string
* id (`row_001`) can be seeded into a `string` column rather than being
* rejected by a `number` column. Reuses the api-types seed-table schema
* (extended with optional `rows`). */
seedDataTables: z.array(instanceAiEvalSeedDataTableSchema).max(20).optional(),
});
const evalTestCaseObjectSchema = z

View File

@ -60,6 +60,37 @@ export function shouldRetryScenarioExecution(message: string, attempt: number):
return false;
}
/**
* Marker prefix for the rootCause stamped on a scenario whose execution was
* aborted by the per-iteration budget/timeout. The lang-tracer side keys its
* `infra_incomplete` attribution off `failureCategory: "framework_issue"` plus
* a timeout-flavoured rootCause keep the wording stable across both repos.
*/
export const BUDGET_TIMEOUT_ROOT_CAUSE =
'Scenario execution exceeded its per-iteration time budget and was aborted before a verdict';
/**
* Classify an error thrown out of scenario execution into the report fields.
*
* Any error that escapes `executeScenario` (after its own retries) is an
* infra/framework problem, never an agent verdict, so it is always
* `framework_issue`. A budget/timeout abort additionally carries a
* timeout-flavoured `rootCause` so partial-result consumers can route it to an
* infra/incomplete bucket instead of counting it against product quality.
*/
export function classifyScenarioExecutionError(errorMessage: string): {
failureCategory: 'framework_issue';
rootCause: string | undefined;
reasoning: string;
} {
const timedOut = isExecutionTimeout(errorMessage);
return {
failureCategory: 'framework_issue',
rootCause: timedOut ? `${BUDGET_TIMEOUT_ROOT_CAUSE}: ${errorMessage}` : undefined,
reasoning: `Scenario execution error: ${errorMessage}`,
};
}
/**
* Eval-DB races abort an execution before any node runs. Two known shapes:
* `SQLITE_CONSTRAINT: FOREIGN KEY constraint failed` and `Workflow <id> not

View File

@ -73,6 +73,32 @@ export function getCaseRunStatus(result: WorkflowTestCaseResult): CaseRunStatus
return 'build_failed';
}
/** Per-case verification tallies across an aggregated run. `notVerified` cases
* where nothing could be scored are counted apart from passed/failed so the
* reported pass rate reflects only what was actually checked. */
export interface CaseVerificationRollup {
/** Verified cases where every evaluated unit passed. */
passed: number;
/** Verified cases with at least one failing evaluated unit. */
failed: number;
/** Cases where no unit could be scored (all incomplete / skipped). */
notVerified: number;
}
export function rollupCaseVerification(cases: TestCaseAggregation[]): CaseVerificationRollup {
const rollup: CaseVerificationRollup = { passed: 0, failed: 0, notVerified: 0 };
for (const tc of cases) {
if (tc.status === 'notVerified') {
rollup.notVerified++;
continue;
}
const { passCount, totalCount } = countAggregatedUnitTrials(getAggregatedCaseUnits(tc));
if (totalCount > 0 && passCount === totalCount) rollup.passed++;
else rollup.failed++;
}
return rollup;
}
export function getCaseRunStatusLabel(result: WorkflowTestCaseResult): string {
switch (getCaseRunStatus(result)) {
case 'checked':

Some files were not shown because too many files have changed in this diff Show More