mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-27 11:05:14 +02:00
Merge branch 'master' into source-control-commit-branch-selection
This commit is contained in:
commit
58ea2a2e77
13
.github/actions/setup-nodejs/action.yml
vendored
13
.github/actions/setup-nodejs/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@
|
|||
"n8n-node-dev",
|
||||
"n8n-nodes-base",
|
||||
"n8n-playwright",
|
||||
"n8n-workflow",
|
||||
"typescript",
|
||||
"@typescript/*"
|
||||
"n8n-workflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
.github/workflows/build-base-image.yml
vendored
2
.github/workflows/build-base-image.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
.github/workflows/ci-master.yml
vendored
4
.github/workflows/ci-master.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
2
.github/workflows/test-linting-reusable.yml
vendored
2
.github/workflows/test-linting-reusable.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/test-unit-reusable.yml
vendored
2
.github/workflows/test-unit-reusable.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 1–5 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 1–5 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 1–5 rating (rather
|
||||
// than a 0–1 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
|
||||
* - 1–5 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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
"@types/qs": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"nock": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"typescript": "catalog:typescript",
|
||||
"vitest": "catalog:",
|
||||
"vitest-mock-extended": "catalog:"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"@n8n/vitest-config": "workspace:*",
|
||||
"@types/express": "catalog:",
|
||||
"@types/lodash": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"typescript": "catalog:typescript",
|
||||
"vitest": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:*"
|
||||
|
|
|
|||
|
|
@ -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> = {}) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
// ******************************************************************
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' } }],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, '..');
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export * from './define';
|
||||
export type * from './types';
|
||||
export * from './define.js';
|
||||
export type * from './types.ts';
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export * from './schema';
|
||||
export * from './schema.js';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"extends": "@n8n/typescript-config/tsconfig.common.json",
|
||||
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"extends": "@n8n/typescript-config/tsconfig.common.json",
|
||||
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"extends": "@n8n/typescript-config/tsconfig.backend.json",
|
||||
"extends": "@n8n/typescript-config/tsconfig.backend.go.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ function fixture(): { evaluation: MultiRunEvaluation; withFiles: WorkflowTestCas
|
|||
expectationAggregation('asks before building', 2, 3),
|
||||
expectationAggregation('never judged', 0, 0),
|
||||
],
|
||||
status: 'verified',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 name→real-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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user