Merge updated Agent migration timestamps from the parent branch

This commit is contained in:
Eugene Molodkin
2026-10-23 17:42:25 +02:00
177 changed files with 3964 additions and 510 deletions
+1 -1
View File
@@ -1775,13 +1775,13 @@ erDiagram
}
"public.self_healing_investigation" {
uuid backgroundUserId FK
json baseline
integer configurationId FK
timestamp_3__with_time_zone createdAt
varchar_36_ executionId
timestamp_3__with_time_zone finishedAt
varchar_36_ id
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
uuid runId
uuid threadId
+1 -1
View File
@@ -261,13 +261,13 @@ erDiagram
}
"public.self_healing_investigation" {
uuid backgroundUserId FK
json baseline
integer configurationId FK
timestamp_3__with_time_zone createdAt
varchar_36_ executionId
timestamp_3__with_time_zone finishedAt
varchar_36_ id
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
uuid runId
uuid threadId
@@ -86,13 +86,13 @@ erDiagram
}
"public.self_healing_investigation" {
uuid backgroundUserId FK
json baseline
integer configurationId FK
timestamp_3__with_time_zone createdAt
varchar_36_ executionId
timestamp_3__with_time_zone finishedAt
varchar_36_ id
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
uuid runId
uuid threadId
@@ -5,13 +5,13 @@
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| backgroundUserId | uuid | | false | | [public.user](public.user.md) | User who enabled the investigation |
| baseline | json | | false | | | Workflow snapshot and version checks captured at admission |
| configurationId | integer | | false | | [public.self_healing_configuration](public.self_healing_configuration.md) | |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| executionId | varchar(36) | | false | | | Original execution reference retained after pruning; supports both execution engines |
| finishedAt | timestamp(3) with time zone | | true | | | When the investigation released its workflow claim |
| id | varchar(36) | | false | | | |
| projectId | varchar(36) | | false | | [public.project](public.project.md) | Workflow owner project at admission |
| publishedVersionId | varchar(36) | | false | | | Published workflow version checked at admission |
| resultId | varchar(36) | | true | | [public.self_healing_result](public.self_healing_result.md) | Result saved by atomic investigation completion |
| runId | uuid | | false | | | Assistant run ID; technical status stays in run events |
| threadId | uuid | | false | | | Private Assistant thread ID; retained after deletion |
@@ -29,12 +29,12 @@
| FK_self_healing_investigation_configuration | FOREIGN KEY | FOREIGN KEY ("configurationId") REFERENCES self_healing_configuration(id) ON DELETE CASCADE |
| PK_735895e1c137cd827884cfa843a | PRIMARY KEY | PRIMARY KEY (id) |
| self_healing_investigation_backgroundUserId_not_null | n | NOT NULL "backgroundUserId" |
| self_healing_investigation_baseline_not_null | n | NOT NULL baseline |
| self_healing_investigation_configurationId_not_null | n | NOT NULL "configurationId" |
| self_healing_investigation_createdAt_not_null | n | NOT NULL "createdAt" |
| self_healing_investigation_executionId_not_null | n | NOT NULL "executionId" |
| self_healing_investigation_id_not_null | n | NOT NULL id |
| self_healing_investigation_projectId_not_null | n | NOT NULL "projectId" |
| self_healing_investigation_publishedVersionId_not_null | n | NOT NULL "publishedVersionId" |
| self_healing_investigation_runId_not_null | n | NOT NULL "runId" |
| self_healing_investigation_threadId_not_null | n | NOT NULL "threadId" |
| self_healing_investigation_updatedAt_not_null | n | NOT NULL "updatedAt" |
@@ -65,13 +65,13 @@ erDiagram
"public.self_healing_investigation" {
uuid backgroundUserId FK
json baseline
integer configurationId FK
timestamp_3__with_time_zone createdAt
varchar_36_ executionId
timestamp_3__with_time_zone finishedAt
varchar_36_ id
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
uuid runId
uuid threadId
@@ -119,13 +119,13 @@ erDiagram
}
"public.self_healing_investigation" {
uuid backgroundUserId FK
json baseline
integer configurationId FK
timestamp_3__with_time_zone createdAt
varchar_36_ executionId
timestamp_3__with_time_zone finishedAt
varchar_36_ id
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
uuid runId
uuid threadId
+1 -1
View File
@@ -444,13 +444,13 @@ erDiagram
}
"public.self_healing_investigation" {
uuid backgroundUserId FK
json baseline
integer configurationId FK
timestamp_3__with_time_zone createdAt
varchar_36_ executionId
timestamp_3__with_time_zone finishedAt
varchar_36_ id
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
uuid runId
uuid threadId
+1 -1
View File
@@ -273,13 +273,13 @@ erDiagram
}
"public.self_healing_investigation" {
uuid backgroundUserId FK
json baseline
integer configurationId FK
timestamp_3__with_time_zone createdAt
varchar_36_ executionId
timestamp_3__with_time_zone finishedAt
varchar_36_ id
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
uuid runId
uuid threadId
+1 -1
View File
@@ -1761,13 +1761,13 @@ erDiagram
}
"self_healing_investigation" {
varchar backgroundUserId FK
TEXT baseline
INTEGER configurationId FK
datetime_3_ createdAt
varchar_36_ executionId
datetime_3_ finishedAt
varchar_36_ id PK
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
varchar runId
varchar threadId
+1 -1
View File
@@ -267,13 +267,13 @@ erDiagram
}
"self_healing_investigation" {
varchar backgroundUserId FK
TEXT baseline
INTEGER configurationId FK
datetime_3_ createdAt
varchar_36_ executionId
datetime_3_ finishedAt
varchar_36_ id PK
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
varchar runId
varchar threadId
+1 -1
View File
@@ -90,13 +90,13 @@ erDiagram
}
"self_healing_investigation" {
varchar backgroundUserId FK
TEXT baseline
INTEGER configurationId FK
datetime_3_ createdAt
varchar_36_ executionId
datetime_3_ finishedAt
varchar_36_ id PK
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
varchar runId
varchar threadId
+3 -3
View File
@@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "self_healing_investigation" ("id" varchar(36) PRIMARY KEY NOT NULL, "workflowId" varchar(36) NOT NULL, "projectId" varchar(36) NOT NULL, "backgroundUserId" varchar NOT NULL, "executionId" varchar(36) NOT NULL, "threadId" varchar NOT NULL, "runId" varchar NOT NULL, "baseline" text NOT NULL, "resultId" varchar(36), "finishedAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "configurationId" integer NOT NULL, CONSTRAINT "FK_39a928c9ea7b7afce4bdd1c6dae" FOREIGN KEY ("resultId") REFERENCES "self_healing_result" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_24923f35caa34c1f264576a7914" FOREIGN KEY ("backgroundUserId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_eb5b7d44599bd9a34f02f071b23" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_7fa67c33c27bcc5d745c75a7eab" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_self_healing_investigation_configuration" FOREIGN KEY ("configurationId") REFERENCES "self_healing_configuration" ("id") ON DELETE CASCADE)
CREATE TABLE "self_healing_investigation" ("id" varchar(36) PRIMARY KEY NOT NULL, "workflowId" varchar(36) NOT NULL, "projectId" varchar(36) NOT NULL, "backgroundUserId" varchar NOT NULL, "executionId" varchar(36) NOT NULL, "threadId" varchar NOT NULL, "runId" varchar NOT NULL, "resultId" varchar(36), "finishedAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "configurationId" integer NOT NULL, "publishedVersionId" varchar(36) NOT NULL, CONSTRAINT "FK_39a928c9ea7b7afce4bdd1c6dae" FOREIGN KEY ("resultId") REFERENCES "self_healing_result" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_24923f35caa34c1f264576a7914" FOREIGN KEY ("backgroundUserId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_eb5b7d44599bd9a34f02f071b23" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_7fa67c33c27bcc5d745c75a7eab" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_self_healing_investigation_configuration" FOREIGN KEY ("configurationId") REFERENCES "self_healing_configuration" ("id") ON DELETE CASCADE)
```
</details>
@@ -16,13 +16,13 @@ CREATE TABLE "self_healing_investigation" ("id" varchar(36) PRIMARY KEY NOT NULL
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| backgroundUserId | varchar | | false | | [user](user.md) | |
| baseline | TEXT | | false | | | |
| configurationId | INTEGER | | false | | [self_healing_configuration](self_healing_configuration.md) | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| executionId | varchar(36) | | false | | | |
| finishedAt | datetime(3) | | true | | | |
| id | varchar(36) | | false | | | |
| projectId | varchar(36) | | false | | [project](project.md) | |
| publishedVersionId | varchar(36) | | false | | | |
| resultId | varchar(36) | | true | | [self_healing_result](self_healing_result.md) | |
| runId | varchar | | false | | | |
| threadId | varchar | | false | | | |
@@ -66,13 +66,13 @@ erDiagram
"self_healing_investigation" {
varchar backgroundUserId FK
TEXT baseline
INTEGER configurationId FK
datetime_3_ createdAt
varchar_36_ executionId
datetime_3_ finishedAt
varchar_36_ id PK
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
varchar runId
varchar threadId
+1 -1
View File
@@ -118,13 +118,13 @@ erDiagram
}
"self_healing_investigation" {
varchar backgroundUserId FK
TEXT baseline
INTEGER configurationId FK
datetime_3_ createdAt
varchar_36_ executionId
datetime_3_ finishedAt
varchar_36_ id PK
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
varchar runId
varchar threadId
+1 -1
View File
@@ -448,13 +448,13 @@ erDiagram
}
"self_healing_investigation" {
varchar backgroundUserId FK
TEXT baseline
INTEGER configurationId FK
datetime_3_ createdAt
varchar_36_ executionId
datetime_3_ finishedAt
varchar_36_ id PK
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
varchar runId
varchar threadId
+1 -1
View File
@@ -272,13 +272,13 @@ erDiagram
}
"self_healing_investigation" {
varchar backgroundUserId FK
TEXT baseline
INTEGER configurationId FK
datetime_3_ createdAt
varchar_36_ executionId
datetime_3_ finishedAt
varchar_36_ id PK
varchar_36_ projectId FK
varchar_36_ publishedVersionId
varchar_36_ resultId FK
varchar runId
varchar threadId
+8
View File
@@ -37,6 +37,9 @@
"llm-output": [
"dist/esm/llm-output/index.d.ts"
],
"gen-ai": [
"dist/esm/gen-ai/index.d.ts"
],
"web-search": [
"dist/esm/web-search/index.d.ts"
],
@@ -101,6 +104,11 @@
"import": "./dist/esm/llm-output/index.js",
"require": "./dist/cjs/llm-output/index.js"
},
"./gen-ai": {
"types": "./dist/esm/gen-ai/index.d.ts",
"import": "./dist/esm/gen-ai/index.js",
"require": "./dist/cjs/gen-ai/index.js"
},
"./web-search": {
"types": "./dist/esm/web-search/index.d.ts",
"import": "./dist/esm/web-search/index.js",
@@ -1101,6 +1101,108 @@ describe('N8nLlmTracing', () => {
const tracingArg = setMetadataMock.mock.calls[0][0].tracing as Record<string, unknown>;
expect(tracingArg).not.toHaveProperty('llm.cost.total');
});
describe('GenAI attributes on the chat model task', () => {
const runId = 'run-gen-ai';
const chatModelMetadata = { ls_model_type: 'chat', ls_model_name: 'gpt-4o-mini' };
const reportedUsageOutput: LLMResult = {
generations: [
[{ text: 'Response', generationInfo: { model_name: 'gpt-4o-mini-2024-07-18' } }],
],
llmOutput: { tokenUsage: { completionTokens: 10, promptTokens: 5, totalTokens: 15 } },
};
const runLlmCall = async (
nodeType: string,
startMetadata: Record<string, unknown> | undefined,
output: LLMResult,
tracerOptions?: ConstructorParameters<typeof N8nLlmTracing>[1],
) => {
mockNode.type = nodeType;
const tracer = new N8nLlmTracing(mockExecutionFunctions, tracerOptions);
const llm: Serialized = { lc: 1, type: 'constructor', id: ['langchain'], kwargs: {} };
await tracer.handleLLMStart(
llm,
['Hi'],
runId,
undefined,
undefined,
undefined,
startMetadata,
);
await tracer.handleLLMEnd(output, runId);
return mockExecutionFunctions.addOutputData.mock.calls[0][3];
};
it('writes the chat attributes with numeric usage to the chat model output', async () => {
const metadata = await runLlmCall(
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
chatModelMetadata,
reportedUsageOutput,
);
expect(metadata).toEqual({
tracing: {
'gen_ai.operation.name': 'chat',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o-mini',
'gen_ai.response.model': 'gpt-4o-mini-2024-07-18',
'gen_ai.usage.input_tokens': 5,
'gen_ai.usage.output_tokens': 10,
},
});
});
it.each([
['@n8n/n8n-nodes-langchain.lmChatOllama', 'ollama', undefined],
['n8n-nodes-community.lmChatCustom', undefined, undefined],
[
'@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
'anthropic',
{ genAiProviderName: 'anthropic' },
],
] as const)(
'sets the provider name of %s to %s',
async (nodeType, providerName, tracerOptions) => {
const metadata = await runLlmCall(
nodeType,
chatModelMetadata,
reportedUsageOutput,
tracerOptions,
);
expect(metadata?.tracing?.['gen_ai.provider.name']).toBe(providerName);
},
);
it('marks usage as not reported instead of sending the estimate', async () => {
const metadata = await runLlmCall(
'@n8n/n8n-nodes-langchain.lmChatOllama',
{ ls_model_type: 'chat' },
{ generations: [[{ text: 'Response' }]], llmOutput: {} },
);
expect(metadata).toEqual({
tracing: {
'gen_ai.operation.name': 'chat',
'gen_ai.provider.name': 'ollama',
'n8n.llm.usage_reported': false,
},
});
});
it('writes no GenAI attributes for a text completion model', async () => {
const metadata = await runLlmCall(
'@n8n/n8n-nodes-langchain.lmOpenAi',
undefined,
reportedUsageOutput,
);
expect(metadata).toBeUndefined();
});
});
});
describe('runsMap management', () => {
@@ -0,0 +1,112 @@
// Copied from the OTel GenAI registry instead of importing them from
// `@opentelemetry/semantic-conventions/incubating`, because OTel asks instrumentation
// libraries to copy unstable definitions:
// https://opentelemetry.io/docs/specs/semconv/non-normative/code-generation/#stability-and-versioning
// https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv
// Source: https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/registry/attributes/gen-ai.md
import type { ITaskMetadata } from 'n8n-workflow';
export const GEN_AI_ATTR = {
OPERATION_NAME: 'gen_ai.operation.name',
PROVIDER_NAME: 'gen_ai.provider.name',
REQUEST_MODEL: 'gen_ai.request.model',
RESPONSE_MODEL: 'gen_ai.response.model',
USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens',
USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens',
AGENT_NAME: 'gen_ai.agent.name',
AGENT_ID: 'gen_ai.agent.id',
} as const;
export const N8N_USAGE_REPORTED_ATTR = 'n8n.llm.usage_reported';
type SpecProviderName =
| 'anthropic'
| 'aws.bedrock'
| 'azure.ai.openai'
| 'cohere'
| 'deepseek'
| 'gcp.gemini'
| 'gcp.vertex_ai'
| 'groq'
| 'mistral'
| 'moonshot_ai'
| 'openai'
| 'x_ai';
type N8nProviderName =
| 'alibaba_cloud'
| 'databricks'
| 'lemonade'
| 'minimax'
| 'nvidia'
| 'ollama'
| 'openrouter'
| 'vercel.ai_gateway';
export type GenAiProviderName = SpecProviderName | N8nProviderName;
export type GenAiAttributes = NonNullable<ITaskMetadata['tracing']>;
const LANGCHAIN_NODE_PREFIX = '@n8n/n8n-nodes-langchain.';
const PROVIDER_BY_CHAT_MODEL_NODE: Readonly<Record<string, GenAiProviderName>> = {
lmChatAlibabaCloud: 'alibaba_cloud',
lmChatAnthropic: 'anthropic',
lmChatAwsBedrock: 'aws.bedrock',
lmChatAzureOpenAi: 'azure.ai.openai',
lmChatCohere: 'cohere',
lmChatDatabricks: 'databricks',
lmChatDeepSeek: 'deepseek',
lmChatGoogleGemini: 'gcp.gemini',
lmChatGoogleVertex: 'gcp.vertex_ai',
lmChatGroq: 'groq',
lmChatLemonade: 'lemonade',
lmChatMinimax: 'minimax',
lmChatMistralCloud: 'mistral',
lmChatMoonshot: 'moonshot_ai',
lmChatNvidia: 'nvidia',
lmChatOllama: 'ollama',
lmChatOpenAi: 'openai',
lmChatOpenRouter: 'openrouter',
lmChatVercelAiGateway: 'vercel.ai_gateway',
lmChatXAiGrok: 'x_ai',
};
const GEN_AI_ATTRIBUTE_KEYS: ReadonlySet<string> = new Set([
...Object.values(GEN_AI_ATTR),
N8N_USAGE_REPORTED_ATTR,
]);
export function isGenAiAttributeKey(key: string): boolean {
return GEN_AI_ATTRIBUTE_KEYS.has(key);
}
export function buildChatGenAiAttributes({
nodeType,
providerName,
requestModel,
responseModel,
usage,
}: {
nodeType: string;
providerName?: GenAiProviderName;
requestModel?: string;
responseModel?: string;
usage?: { promptTokens: number; completionTokens: number };
}): GenAiAttributes {
const provider =
providerName ?? PROVIDER_BY_CHAT_MODEL_NODE[nodeType.replace(LANGCHAIN_NODE_PREFIX, '')];
return {
[GEN_AI_ATTR.OPERATION_NAME]: 'chat',
...(provider && { [GEN_AI_ATTR.PROVIDER_NAME]: provider }),
...(requestModel && { [GEN_AI_ATTR.REQUEST_MODEL]: requestModel }),
...(responseModel && { [GEN_AI_ATTR.RESPONSE_MODEL]: responseModel }),
...(usage
? {
[GEN_AI_ATTR.USAGE_INPUT_TOKENS]: usage.promptTokens,
[GEN_AI_ATTR.USAGE_OUTPUT_TOKENS]: usage.completionTokens,
}
: { [N8N_USAGE_REPORTED_ATTR]: false }),
};
}
@@ -0,0 +1,7 @@
export {
GEN_AI_ATTR,
N8N_USAGE_REPORTED_ATTR,
buildChatGenAiAttributes,
isGenAiAttributeKey,
} from './attributes';
export type { GenAiAttributes, GenAiProviderName } from './attributes';
@@ -7,11 +7,12 @@ import type {
SerializedSecret,
} from '@langchain/core/load/serializable';
import type { BaseMessage } from '@langchain/core/messages';
import type { LLMResult } from '@langchain/core/outputs';
import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow';
import type { Generation, LLMResult } from '@langchain/core/outputs';
import type { IDataObject, ISupplyDataFunctions, ITaskMetadata, JsonObject } from 'n8n-workflow';
import { NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow';
import { toRunDataGeneration } from './extract-responding-model';
import { buildChatGenAiAttributes, type GenAiProviderName } from '../gen-ai';
import { extractRespondingModel, toRunDataGeneration } from './extract-responding-model';
import { logAiEvent } from './log-ai-event';
import { redactHeaderValues } from './redact-headers';
import { stripNonXHeaders } from './strip-non-x-headers';
@@ -58,6 +59,8 @@ type RunDetail = {
index: number;
messages: BaseMessage[] | string[] | string;
options: SerializedSecret | SerializedNotImplemented | SerializedFields;
isChatModel?: boolean;
requestModel?: string;
};
const TIKTOKEN_ESTIMATE_MODEL = 'gpt-4o';
@@ -111,6 +114,7 @@ export class N8nLlmTracing extends BaseCallbackHandler {
tokensUsageParser: TokensUsageParser;
errorDescriptionMapper: (error: NodeError) => string | null | undefined;
redactedHeaders?: string[];
genAiProviderName?: GenAiProviderName;
} = {
// Default(OpenAI format) parser
tokensUsageParser: (result: LLMResult) => {
@@ -137,6 +141,7 @@ export class N8nLlmTracing extends BaseCallbackHandler {
tokensUsageParser?: TokensUsageParser;
errorDescriptionMapper?: (error: NodeError) => string;
redactedHeaders?: string[];
genAiProviderName?: GenAiProviderName;
},
) {
super();
@@ -184,8 +189,10 @@ export class N8nLlmTracing extends BaseCallbackHandler {
response: { generations: output.generations },
};
const isUsageReported = tokenUsage.completionTokens > 0;
// If the LLM response contains actual tokens usage, otherwise fallback to the estimate
if (tokenUsage.completionTokens > 0) {
if (isUsageReported) {
response.tokenUsage = tokenUsage;
this.applyTracingTokenMetadata({
promptTokens: tokenUsage.promptTokens,
@@ -221,7 +228,11 @@ export class N8nLlmTracing extends BaseCallbackHandler {
this.connectionType,
runDetails.index,
[[{ json: { ...response } }]],
undefined,
this.buildGenAiMetadata(
runDetails,
output.generations[0]?.[0],
isUsageReported ? tokenUsage : undefined,
),
sourceNodeRunIndex,
);
@@ -232,7 +243,15 @@ export class N8nLlmTracing extends BaseCallbackHandler {
});
}
async handleLLMStart(llm: Serialized, prompts: string[], runId: string) {
async handleLLMStart(
llm: Serialized,
prompts: string[],
runId: string,
_parentRunId?: string,
_extraParams?: Record<string, unknown>,
_tags?: string[],
metadata?: Record<string, unknown>,
) {
const estimatedTokens = await this.estimateTokensFromStringList(prompts);
const sourceNodeRunIndex =
this.#parentRunIndex !== undefined
@@ -264,6 +283,9 @@ export class N8nLlmTracing extends BaseCallbackHandler {
index,
options,
messages: prompts,
isChatModel: metadata?.ls_model_type === 'chat',
requestModel:
typeof metadata?.ls_model_name === 'string' ? metadata.ls_model_name : undefined,
};
this.promptTokensEstimate = estimatedTokens;
}
@@ -303,6 +325,24 @@ export class N8nLlmTracing extends BaseCallbackHandler {
this.#parentRunIndex = runIndex;
}
private buildGenAiMetadata(
runDetails: Partial<RunDetail>,
generation: Generation | undefined,
usage: TokenUsageResult | undefined,
): ITaskMetadata | undefined {
if (!runDetails.isChatModel) return undefined;
return {
tracing: buildChatGenAiAttributes({
nodeType: this.executionFunctions.getNode().type,
providerName: this.options.genAiProviderName,
requestModel: runDetails.requestModel,
responseModel: generation ? extractRespondingModel(generation) : undefined,
usage,
}),
};
}
private applyTracingTokenMetadata(params: {
promptTokens: number;
completionTokens: number;
@@ -63,6 +63,8 @@ export class CreateCredentialPublicDto extends Z.class({
data: z.record(z.string(), z.unknown()).openapi({
writeOnly: true,
example: { accessToken: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' },
description:
'The credential data. Fields also accept n8n expressions, which are strings that start with "=".',
}),
isResolvable: z
.boolean()
@@ -106,7 +108,9 @@ export class UpdateCredentialPublicDto extends Z.class({
.openapi({
writeOnly: true,
example: { accessToken: 'new_token_value' },
description: 'The credential data. Required when changing credential type.',
description:
'The credential data. Required when changing credential type. ' +
'Fields also accept n8n expressions, which are strings that start with "=".',
}),
isGlobal: z.boolean().optional().openapi({
example: false,
+2
View File
@@ -835,9 +835,11 @@ export {
export {
parsePromotionRunDetails,
promotionGitRunDetailsSchema,
promotionRunContentsSchema,
promotionRunDetailsSchemas,
promotionRunTypeSchema,
type PromotionGitRunDetails,
type PromotionRunContents,
type PromotionRunDetails,
type PromotionRunType,
} from './promotions/promotion-run.dto';
@@ -16,6 +16,15 @@ describe('promotion run details', () => {
it('rejects a git payload with another schema version', () => {
expect(() => parsePromotionRunDetails('git', { ...gitDetails, schemaVersion: 2 })).toThrow();
});
it('accepts the contents that the run pushed', () => {
const details = { ...gitDetails, contents: { projectIds: ['p1', 'p2'] } };
expect(parsePromotionRunDetails('git', details)).toEqual(details);
expect(() =>
parsePromotionRunDetails('git', { ...gitDetails, contents: { projectIds: [''] } }),
).toThrow();
});
});
describe('promotion review host reference', () => {
@@ -69,9 +69,18 @@ export const promotionResourceDependencySchema = z.object({
export type PromotionResourceDependency = z.infer<typeof promotionResourceDependencySchema>;
/** Why a promote preview shows no changes. `reviewId` is the open review that holds them. */
export const promotionBlockerSchema = z.object({
code: z.literal('reviewOpen'),
reviewId: z.string(),
});
export type PromotionBlocker = z.infer<typeof promotionBlockerSchema>;
export const promotionPreviewSchema = promotionChangesSchema.extend({
resources: z.array(promotionResourceSchema),
dependencies: z.array(promotionResourceDependencySchema),
blocker: promotionBlockerSchema.optional(),
});
export type PromotionPreview = z.infer<typeof promotionPreviewSchema>;
@@ -7,15 +7,28 @@ import { z } from 'zod';
export const promotionRunTypeSchema = z.enum(['git']);
export type PromotionRunType = z.infer<typeof promotionRunTypeSchema>;
/**
* What a run carries. A run without `projectIds` covers every project of its environment.
* This is the place to add finer-grained ids, such as workflows.
*/
export const promotionRunContentsSchema = z
.object({
projectIds: z.array(z.string().min(1)).optional(),
})
.strict();
export type PromotionRunContents = z.infer<typeof promotionRunContentsSchema>;
/**
* Payload for a `git` run. `branchName` is the branch the run pushed to or
* applied from. `commitSha` is the commit it pushed or applied.
* applied from. `commitSha` is the commit it pushed or applied. `contents` is what the
* run pushed.
*/
export const promotionGitRunDetailsSchema = z
.object({
schemaVersion: z.literal(1),
branchName: z.string().min(1).max(255),
commitSha: z.string().min(1).max(64),
contents: promotionRunContentsSchema.optional(),
})
.strict();
export type PromotionGitRunDetails = z.infer<typeof promotionGitRunDetailsSchema>;
@@ -38,7 +38,17 @@ export type SelfHealingExecutionReference =
| { status: 'available'; id: string }
| { status: 'unavailable' };
export type SelfHealingReviewState = 'open' | 'applied' | 'discarded' | 'outdated' | 'dismissed';
/**
* `closed` marks a Could not fix result that closed automatically.
* Only a user dismissal returns `dismissed`.
*/
export type SelfHealingReviewState =
| 'open'
| 'applied'
| 'discarded'
| 'outdated'
| 'dismissed'
| 'closed';
export type SelfHealingResultDetail = SelfHealingResultContent & {
resultId: string;
@@ -0,0 +1,50 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = 'self_healing_investigation';
/**
* Stores only the published version. The run keeps the captured baseline in memory, and the
* cooldown needs only this version.
*/
export class ReplaceSelfHealingInvestigationBaseline1792750863544 implements ReversibleMigration {
async up({
schemaBuilder: { addColumns, dropColumns, column },
escape,
runQuery,
}: MigrationContext) {
// The feature is behind a flag. Clear investigations instead of converting saved baselines.
// Results do not depend on their investigation, so they stay.
await runQuery(`DELETE FROM ${escape.tableName(table)}`);
await dropColumns(table, ['baseline'], { recreatesOnSqlite: true });
await addColumns(
table,
[
column('publishedVersionId')
.varchar(36)
.notNull.comment('Published workflow version checked at admission'),
],
{ recreatesOnSqlite: true },
);
}
async down({
schemaBuilder: { addColumns, dropColumns, column },
escape,
runQuery,
}: MigrationContext) {
// The previous schema requires the workflow snapshot. Clear investigations as in up().
await runQuery(`DELETE FROM ${escape.tableName(table)}`);
await dropColumns(table, ['publishedVersionId'], { recreatesOnSqlite: true });
await addColumns(
table,
[
column('baseline').json.notNull.comment(
'Workflow snapshot and version checks captured at admission',
),
],
{ recreatesOnSqlite: true },
);
}
}
@@ -1,6 +1,6 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddAgentVersionMetadata1792743375714 implements ReversibleMigration {
export class AddAgentVersionMetadata1792769938200 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
// Raw ALTER preserves incoming SQLite foreign keys and their rows.
for (const name of ['name', 'description']) {
@@ -1,6 +1,6 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateAgentPublishHistory1792743375715 implements ReversibleMigration {
export class CreateAgentPublishHistory1792769938398 implements ReversibleMigration {
async up(ctx: MigrationContext) {
const { escape, runQuery } = ctx;
// Raw ALTER preserves incoming SQLite foreign keys.
@@ -0,0 +1,85 @@
import jwt from 'jsonwebtoken';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ACTION_TOKEN } from '../action-token';
import { IDENTITY_TOKEN } from '../identity-token';
import { mintResumeToken, RESUME_TOKEN, verifyResumeToken } from '../resume-token';
import { signSharedSecretToken } from '../shared-secret-token';
const secret = 'a'.repeat(32);
const executionId = '0199a1b2-0000-7000-8000-000000000001';
const verify = (token: string, id = executionId) => verifyResumeToken(secret, token, id);
describe('verifyResumeToken', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('rejects a token minted for another execution', () => {
const token = mintResumeToken(secret, '0199a1b2-0000-7000-8000-000000000002');
expect(verify(token)).toBe(false);
});
it('rejects a token signed with another secret', () => {
expect(verify(mintResumeToken('b'.repeat(32), executionId))).toBe(false);
});
it('rejects everything when the secret is too short', () => {
const short = 'a'.repeat(31);
expect(verifyResumeToken(short, mintResumeToken(short, executionId), executionId)).toBe(false);
});
// The claims are valid, so only the issuer or the audience can reject the token.
it.each([
IDENTITY_TOKEN,
ACTION_TOKEN,
{ ...RESUME_TOKEN, issuer: IDENTITY_TOKEN.issuer },
{ ...RESUME_TOKEN, audience: IDENTITY_TOKEN.audience },
])('rejects resume claims under a foreign issuer or audience %#', (spec) => {
const token = signSharedSecretToken(spec, secret, { k: 'execution-bound', eid: executionId });
expect(verify(token)).toBe(false);
});
it('accepts its own token, which carries no expiry and still verifies a year later', () => {
const token = mintResumeToken(secret, executionId);
expect(verify(token)).toBe(true);
expect(jwt.decode(token)).not.toHaveProperty('exp');
vi.advanceTimersByTime(365 * 24 * 60 * 60 * 1000);
expect(verify(token)).toBe(true);
});
it.each([{}, { k: 'step-bound' }])(
'rejects a token whose kind is not execution-bound %#',
(kind) => {
const token = jwt.sign({ ...kind, eid: executionId }, secret, {
algorithm: 'HS256',
issuer: RESUME_TOKEN.issuer,
audience: RESUME_TOKEN.audience,
});
expect(verify(token)).toBe(false);
},
);
it.each([
[29, true],
[31, false],
])('allows %i seconds of clock skew on the issue time: %s', (secondsAhead, accepted) => {
const token = jwt.sign(
{ k: 'execution-bound', eid: executionId, iat: Math.floor(Date.now() / 1000) + secondsAhead },
secret,
{ algorithm: 'HS256', issuer: RESUME_TOKEN.issuer, audience: RESUME_TOKEN.audience },
);
expect(verify(token)).toBe(accepted);
});
});
+1
View File
@@ -14,3 +14,4 @@ export {
SharedSecretIdentityVerifier,
} from './identity-token';
export type { AuthenticatedCaller, IdentityVerifier } from './identity.types';
export { mintResumeToken, RESUME_TOKEN, verifyResumeToken } from './resume-token';
@@ -0,0 +1,47 @@
import { z } from 'zod';
import {
signSharedSecretToken,
verifySharedSecretToken,
type UnexpiringTokenSpec,
} from './shared-secret-token';
/**
* The capability token a resume URL carries (ADR-20260904-a-derived-token-authorizes-a-resume-request).
*
* It has its own audience. No other verifier accepts it, and its verifier
* accepts no other token. No lifetime: the status of the step decides whether
* a request still applies, and a wait can last months.
*/
export const RESUME_TOKEN: UnexpiringTokenSpec = Object.freeze({
issuer: 'n8n-engine-dp',
audience: 'n8n-engine-dp/resume',
ttlSeconds: null,
clockToleranceSeconds: 30,
});
/**
* An execution-bound token names the execution and not the step:
* `$execution.resumeUrl` is built by whichever node reads it, usually before
* the step that waits exists. The token states its kind in `k`, so a wait
* bound to a step can refuse it.
*/
const resumeClaimsSchema = z.object({
k: z.literal('execution-bound'),
eid: z.string().min(1),
iat: z.number().int(),
});
/** Mints an execution-bound token for a resume URL of `executionId`. */
export function mintResumeToken(secret: string, executionId: string): string {
return signSharedSecretToken(RESUME_TOKEN, secret, { k: 'execution-bound', eid: executionId });
}
/**
* Whether `token` is an execution-bound token for `executionId`. An unset or
* under-length secret rejects everything.
*/
export function verifyResumeToken(secret: string, token: string, executionId: string): boolean {
const claims = verifySharedSecretToken(RESUME_TOKEN, secret, token, resumeClaimsSchema);
return claims?.eid === executionId;
}
@@ -15,8 +15,13 @@ export interface SharedSecretTokenSpec {
readonly clockToleranceSeconds: number;
}
/** `null` rather than an omitted field, so a spec cannot lose its expiry by accident. */
export type UnexpiringTokenSpec = Omit<SharedSecretTokenSpec, 'ttlSeconds'> & {
readonly ttlSeconds: null;
};
export function signSharedSecretToken(
spec: SharedSecretTokenSpec,
spec: SharedSecretTokenSpec | UnexpiringTokenSpec,
secret: string,
claims: object,
): string {
@@ -24,7 +29,7 @@ export function signSharedSecretToken(
algorithm: 'HS256',
issuer: spec.issuer,
audience: spec.audience,
expiresIn: spec.ttlSeconds,
...(spec.ttlSeconds === null ? {} : { expiresIn: spec.ttlSeconds }),
});
}
@@ -37,7 +42,7 @@ export function signSharedSecretToken(
* "token is bad" must look the same to a caller.
*/
export function verifySharedSecretToken<T extends { iat: number }>(
spec: SharedSecretTokenSpec,
spec: SharedSecretTokenSpec | UnexpiringTokenSpec,
secret: string,
token: string,
schema: z.ZodType<T>,
@@ -54,7 +59,7 @@ export function verifySharedSecretToken<T extends { iat: number }>(
algorithms: ['HS256'],
issuer: spec.issuer,
audience: spec.audience,
maxAge: spec.ttlSeconds,
...(spec.ttlSeconds === null ? {} : { maxAge: spec.ttlSeconds }),
clockTolerance: spec.clockToleranceSeconds,
clockTimestamp: now,
});
@@ -392,7 +392,6 @@ describe('workflow_step_execution table (integration)', () => {
const wait = {
resumeAt: '2099-01-01T00:00:00.000Z',
outputsAtDeadline: [[{ json: { passed: 'through' } }]],
acceptsResumeRequest: false,
};
expect(await store.suspendStep(id, wait)).toBe(true);
@@ -413,7 +412,7 @@ describe('workflow_step_execution table (integration)', () => {
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
const { id } = await seedStep({ executionId, nodeId: 'a', iteration: 0, status: 'running' });
expect(await store.suspendStep(id, { acceptsResumeRequest: true })).toBe(true);
expect(await store.suspendStep(id, { resumeBinding: 'execution' })).toBe(true);
const found = await dataSource
.getRepository(WorkflowStepExecution)
@@ -432,7 +431,7 @@ describe('workflow_step_execution table (integration)', () => {
status: 'queued',
});
expect(await store.suspendStep(id, { acceptsResumeRequest: true })).toBe(false);
expect(await store.suspendStep(id, { resumeBinding: 'execution' })).toBe(false);
const found = await dataSource
.getRepository(WorkflowStepExecution)
@@ -445,7 +444,7 @@ describe('workflow_step_execution table (integration)', () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
const { id } = await seedStep({ executionId, nodeId: 'a', iteration: 0, status: 'running' });
await store.suspendStep(id, { acceptsResumeRequest: true });
await store.suspendStep(id, { resumeBinding: 'execution' });
// expected but pending: the execution stays open until the wait resolves
expect(await store.countSettledSteps(executionId)).toBe(0);
@@ -454,7 +453,7 @@ describe('workflow_step_execution table (integration)', () => {
it('TypeOrmStepStore.resumeStep queues a waiting step and records what resumed it', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
const wait: WaitDeclaration = { acceptsResumeRequest: true };
const wait: WaitDeclaration = { resumeBinding: 'execution' };
const { id } = await seedStep({
executionId,
nodeId: 'a',
@@ -499,7 +498,6 @@ describe('workflow_step_execution table (integration)', () => {
const wait: WaitDeclaration = {
resumeAt: '2099-01-01T00:00:00.000Z',
outputsAtDeadline: [[{ json: { passed: 'through' } }]],
acceptsResumeRequest: false,
};
const { id } = await seedStep({
executionId,
@@ -530,11 +528,10 @@ describe('workflow_step_execution table (integration)', () => {
status: 'waiting',
waitDeclaration:
waitTill === null
? { acceptsResumeRequest: true }
? { resumeBinding: 'execution' }
: {
resumeAt: waitTill.toISOString(),
outputsAtDeadline: [[{ json: { passed: 'through' } }]],
acceptsResumeRequest: false,
},
waitTill,
});
@@ -257,7 +257,6 @@ describe('step execution (integration)', () => {
const wait = {
resumeAt: '2099-01-01T00:00:00.000Z',
outputsAtDeadline: [[{ json: { passed: 'through' } }]],
acceptsResumeRequest: false,
};
const executor: IStepExecutor = {
execute: async () => {
@@ -299,7 +298,6 @@ describe('step execution (integration)', () => {
const wait = {
resumeAt: '2020-01-01T00:00:00.000Z',
outputsAtDeadline: [[{ json: { passed: 'through' } }]],
acceptsResumeRequest: false,
};
const requests: StepExecutionRequest[] = [];
const executor: IStepExecutor = {
@@ -355,7 +353,6 @@ describe('step execution (integration)', () => {
const wait = {
resumeAt: '2099-01-01T00:00:00.000Z',
outputsAtDeadline: [[{ json: { passed: 'through' } }]],
acceptsResumeRequest: false,
};
const executor: IStepExecutor = {
execute: async (request) => {
@@ -810,7 +810,6 @@ describe('StepReadyHandler waits', () => {
const timeWait: WaitDeclaration = {
resumeAt: '2099-01-01T00:00:00.000Z',
outputsAtDeadline: [[{ json: { passed: 'through' } }]],
acceptsResumeRequest: false,
};
it('suspends the step and announces no settlement when the executor declares a wait', async () => {
@@ -872,7 +871,7 @@ describe('StepReadyHandler waits', () => {
});
it('suspends a wait that only a resume request ends', async () => {
const openWait: WaitDeclaration = { acceptsResumeRequest: true };
const openWait: WaitDeclaration = { resumeBinding: 'execution' };
const stepStore = makeStepStore();
const handler = makeHandler(makeExecutionStore(), stepStore, makeQueue(), {
v1StepExecutor: makeExecutor({ wait: openWait }),
@@ -895,7 +894,6 @@ describe('StepReadyHandler waits', () => {
wait: {
resumeAt: 'the day after tomorrow',
outputsAtDeadline: [[{ json: {} }]],
acceptsResumeRequest: false,
},
}),
});
@@ -919,7 +917,7 @@ describe('StepReadyHandler waits', () => {
const queue = makeQueue();
const handler = makeHandler(makeExecutionStore(), stepStore, queue, {
v1StepExecutor: makeExecutor({
wait: { resumeAt: '2099-01-01T00:00:00.000Z', acceptsResumeRequest: false },
wait: { resumeAt: '2099-01-01T00:00:00.000Z' },
}),
});
@@ -934,39 +932,41 @@ describe('StepReadyHandler waits', () => {
);
});
it('fails the step when the declaration can never resume', async () => {
// No deadline and no resume request means nothing would ever end this
// wait, so suspending would strand the execution. The declaration comes
// across the executor seam, so this is the executor's bug — but the node
// has already run, and a step that ran is recorded, not left running.
const stepStore = makeStepStore();
const queue = makeQueue();
const handler = makeHandler(makeExecutionStore(), stepStore, queue, {
v1StepExecutor: makeExecutor({ wait: { acceptsResumeRequest: false } }),
});
it.each([{}, { resumeBinding: null }, { resumeBinding: 'bogus' }])(
'fails the step when the declaration can never resume %#',
async (declaration) => {
// No deadline and no resume request means nothing would ever end this
// wait, so suspending would strand the execution. The declaration comes
// across the executor seam, so this is the executor's bug — but the node
// has already run, and a step that ran is recorded, not left running.
const stepStore = makeStepStore();
const queue = makeQueue();
const handler = makeHandler(makeExecutionStore(), stepStore, queue, {
v1StepExecutor: makeExecutor({ wait: declaration as WaitDeclaration }),
});
await handler.handle(event);
await handler.handle(event);
expect(stepStore.suspendStep).not.toHaveBeenCalled();
expect(stepStore.failStep).toHaveBeenCalledWith(
'step-a',
expect.objectContaining({
message: expect.stringContaining('declares a wait that can never resume') as string,
}),
);
expect(queue.publish).toHaveBeenCalledWith({
type: 'step:settled',
executionId: 'exec-1',
stepId: 'step-a',
});
});
expect(stepStore.suspendStep).not.toHaveBeenCalled();
expect(stepStore.failStep).toHaveBeenCalledWith(
'step-a',
expect.objectContaining({
message: expect.stringContaining('declares a wait that can never resume') as string,
}),
);
expect(queue.publish).toHaveBeenCalledWith({
type: 'step:settled',
executionId: 'exec-1',
stepId: 'step-a',
});
},
);
});
describe('StepReadyHandler resumes', () => {
const timeWait: WaitDeclaration = {
resumeAt: '2099-01-01T00:00:00.000Z',
outputsAtDeadline: [[{ json: { passed: 'through' } }]],
acceptsResumeRequest: false,
};
it('emits the captured outputs when a deadline resume dispatches the step', async () => {
@@ -1025,7 +1025,7 @@ describe('StepReadyHandler resumes', () => {
const requestOutputs = [[{ json: { approved: true } }]];
const stepStore = makeStepStore({
status: 'running',
waitDeclaration: { acceptsResumeRequest: true },
waitDeclaration: { resumeBinding: 'execution' },
resumeCause: { kind: 'request', outputs: requestOutputs },
});
const queue = makeQueue();
@@ -137,16 +137,19 @@ export type WaitDeclaration =
resumeAt: string;
/** The slots the step emits when the deadline fires. */
outputsAtDeadline: StepSlots;
/** Whether a resume request may end the wait early. */
acceptsResumeRequest: boolean;
/** Which resume request may end the wait early. If absent, none can. */
resumeBinding?: ResumeBinding;
}
| {
/** No deadline, so only a resume request ends this wait. */
resumeAt?: never;
outputsAtDeadline?: never;
acceptsResumeRequest: true;
resumeBinding: ResumeBinding;
};
/** What a resume request for a wait must name (ADR-20260904-a-derived-token-authorizes-a-resume-request). */
export type ResumeBinding = 'execution' | 'step';
/**
* What ended a step's wait, recorded on the row when it resumed. No node code
* runs on a resume. A deadline carries nothing: the declaration already holds
@@ -162,7 +165,11 @@ export type ResumeCause = { kind: 'deadline' } | { kind: 'request'; outputs: Ste
* system, as `assertCreatableRecord` does for step creation.
*/
export function hasResumeCondition(wait: WaitDeclaration): boolean {
return wait.acceptsResumeRequest || wait.resumeAt !== undefined;
return (
wait.resumeBinding === 'execution' ||
wait.resumeBinding === 'step' ||
wait.resumeAt !== undefined
);
}
/**
@@ -13,6 +13,7 @@ export type {
StepError,
StepKey,
StepKeyId,
ResumeBinding,
ResumeCause,
StepSlots,
StepStatus,
@@ -413,7 +413,7 @@ function resumedOutputs(step: StepRecord, cause: ResumeCause): StepSlots {
function validateWaitDeclaration(stepId: string, wait: WaitDeclaration): void {
if (!hasResumeCondition(wait)) {
throw new UnexpectedError(
`step ${stepId} declares a wait that can never resume: it names neither a deadline nor a resume request`,
`step ${stepId} declares a wait that can never resume: it names neither a deadline nor a resume binding`,
);
}
+1
View File
@@ -10,6 +10,7 @@ export {
InvalidIdentityTokenError,
mintActionToken,
mintIdentityToken,
mintResumeToken,
SharedSecretIdentityVerifier,
verifyActionToken,
} from './auth';
@@ -830,7 +830,7 @@ describe('POST /api/workflow-executions/:id/cancel (integration)', () => {
nodeId: 'b',
iteration: 0,
status: 'waiting',
waitDeclaration: { acceptsResumeRequest: true },
waitDeclaration: { resumeBinding: 'execution' },
}),
);
@@ -1122,6 +1122,7 @@ describe('createInstanceAgent', () => {
// Embedded specialist agents reuse context.modelId / orchestration context.modelId,
// so mutating the sticky header here covers those paths too.
const modalModel = {
kind: 'endpoint' as const,
id: 'custom/moonshotai/Kimi-K3' as const,
url: 'https://n8ngmbh--ep-kimi-k3-server.us-west.modal.direct/v1',
apiKey: 'wk-test.ws-test',
+1
View File
@@ -765,6 +765,7 @@ export type {
LocalMcpServer,
McpServerConfig,
ModelConfig,
EndpointModelConfig,
VertexAnthropicModelConfig,
InstanceAiMemoryConfig,
CreateInstanceAgentOptions,
@@ -213,6 +213,7 @@ describe('classifyNodesForSimulation', () => {
}),
);
const fallbackModelConfig = {
kind: 'endpoint' as const,
id: 'anthropic/claude-opus-4-8' as const,
url: 'https://proxy.example.com/anthropic/v1',
apiKey: 'proxy-token',
@@ -160,6 +160,7 @@ describe('generateSimulationFixtures', () => {
it('forwards the fallback model config to the LLM call', async () => {
setupAgentMock(JSON.stringify({ A: [{ json: { ok: true } }] }));
const fallbackModelConfig = {
kind: 'endpoint' as const,
id: 'anthropic/claude-opus-4-8' as const,
url: 'https://proxy.example.com/anthropic/v1',
apiKey: 'proxy-token',
@@ -65,6 +65,7 @@ describe('planVerificationSimulation — simulated trigger verdicts', () => {
it('forwards the fallback model config to classification and fixture generation', async () => {
const fallbackModelConfig = {
kind: 'endpoint' as const,
id: 'anthropic/claude-opus-4-8' as const,
url: 'https://proxy.example.com/anthropic/v1',
apiKey: 'proxy-token',
+13 -1
View File
@@ -2043,8 +2043,19 @@ export interface InstanceAiMemoryConfig {
type NativeLanguageModelConfig = Extract<NativeModelConfig, { specificationVersion: string }>;
/** OpenAI-compatible or api-key-only endpoint. An empty `url` keeps the provider default. */
export type EndpointModelConfig = {
kind: 'endpoint';
id: `${string}/${string}`;
url: string;
apiKey?: string;
headers?: Record<string, string>;
supportsStructuredOutputs?: boolean;
};
/** Direct Google Vertex Claude (Anthropic Messages via `:rawPredict`). */
export type VertexAnthropicModelConfig = {
kind: 'vertex-anthropic';
id: `google-vertex-anthropic/${string}`;
project: string;
location: string;
@@ -2054,10 +2065,11 @@ export type VertexAnthropicModelConfig = {
/** Model identifier: plain string for built-in providers, object for OpenAI-compatible endpoints,
* Vertex Claude, or a pre-built LanguageModel instance (e.g. from @ai-sdk/anthropic with a custom baseURL).
*
* Object configs carry a `kind` tag, so code never infers the variant from its fields.
* The LanguageModel variant exists for proxy routes that need a provider-native transport. */
export type ModelConfig =
| string
| { id: `${string}/${string}`; url: string; apiKey?: string; headers?: Record<string, string> }
| EndpointModelConfig
| VertexAnthropicModelConfig
| NativeLanguageModelConfig;
@@ -19,6 +19,7 @@ describe('isModalModelEndpoint', () => {
describe('withModalSession', () => {
const modalConfig = {
kind: 'endpoint' as const,
id: 'custom/moonshotai/Kimi-K3' as const,
url: 'https://n8ngmbh--ep-kimi-k3-server.us-west.modal.direct/v1',
apiKey: 'wk-test.ws-test',
@@ -52,6 +53,7 @@ describe('withModalSession', () => {
it('is a no-op for non-Modal endpoints and non-endpoint configs', () => {
const databricks = {
kind: 'endpoint' as const,
id: 'custom/workspace.default.kimi-k3' as const,
url: 'https://dbc-a5846527-f2d2.cloud.databricks.com/ai-gateway/mlflow/v1',
apiKey: 'dapi-test',
@@ -1,25 +1,9 @@
import { isRecord } from '@n8n/utils/is-record';
import type { ModelConfig } from '../types';
import type { EndpointModelConfig, ModelConfig } from '../types';
export const MODAL_SESSION_HEADER = 'Modal-Session-ID';
export function isEndpointModelConfig(modelId: ModelConfig): modelId is {
id: `${string}/${string}`;
url: string;
apiKey?: string;
headers?: Record<string, string>;
supportsStructuredOutputs?: boolean;
} {
if (!isRecord(modelId)) return false;
if ('doGenerate' in modelId || 'specificationVersion' in modelId) return false;
if (!('url' in modelId) || !('id' in modelId)) return false;
const id = modelId.id;
const url = modelId.url;
if (typeof id !== 'string' || !id.includes('/')) return false;
if (typeof url !== 'string') return false;
return true;
export function isEndpointModelConfig(modelId: ModelConfig): modelId is EndpointModelConfig {
return typeof modelId === 'object' && 'kind' in modelId && modelId.kind === 'endpoint';
}
export function isModalModelEndpoint(url: string | undefined): boolean {
@@ -427,12 +427,12 @@ describe('V1StepExecutor', () => {
// The flag defaults to true, as it does in core. The deadline emits the
// node's input, which is what v1 passes through on a timed resume.
it.each([
['omitted', true],
['true', true],
['false', false],
['omitted', { resumeBinding: 'execution' }],
['true', { resumeBinding: 'execution' }],
['false', {}],
])(
'declares a deadline wait with acceptsResumeRequest %s',
async (acceptsResumeRequest, declared) => {
async (acceptsResumeRequest, binding) => {
const graph = graphWith('test.waitsUntil', {
waitTill: '2026-10-01T12:00:00.000Z',
acceptsResumeRequest,
@@ -442,7 +442,7 @@ describe('V1StepExecutor', () => {
wait: {
resumeAt: '2026-10-01T12:00:00.000Z',
outputsAtDeadline: input,
acceptsResumeRequest: declared,
...binding,
},
});
},
@@ -512,7 +512,8 @@ describe('V1StepExecutor', () => {
const result = await testStepExecutor(graph).execute(stepRequest(graph, 'n', input));
const after = Date.now();
expect(result.wait).toMatchObject({ outputsAtDeadline: input, acceptsResumeRequest: false });
expect(result.wait).toMatchObject({ outputsAtDeadline: input });
expect(result.wait).not.toHaveProperty('resumeBinding');
const resumeAt = Date.parse(result.wait!.resumeAt!);
expect(resumeAt).toBeGreaterThanOrEqual(before + 2000);
expect(resumeAt).toBeLessThanOrEqual(after + 2000);
@@ -75,7 +75,7 @@ function toStepResult(
wait: {
resumeAt: waitTill.toISOString(),
outputsAtDeadline: toStepOutputs([context.getInputData()]),
acceptsResumeRequest: context.acceptsResumeRequest,
...(context.acceptsResumeRequest ? { resumeBinding: 'execution' as const } : {}),
},
};
}
@@ -223,7 +223,12 @@ export class LmChatAzureOpenAi implements INodeType {
maxTokens: options.maxTokens && options.maxTokens > 0 ? options.maxTokens : undefined,
maxRetries: options.maxRetries ?? 2,
invocationKwargs: extraBodyKwargs,
callbacks: [new N8nLlmTracing(this, { tokensUsageParser: anthropicTokensUsageParser })],
callbacks: [
new N8nLlmTracing(this, {
tokensUsageParser: anthropicTokensUsageParser,
genAiProviderName: 'anthropic',
}),
],
onFailedAttempt: makeN8nLlmFailedAttemptHandler(
this,
makeAzureFoundryFailedAttemptHandler(modelName, false, 'anthropic'),
@@ -368,6 +368,7 @@ describe('N8nLlmTracing', () => {
index: 0,
options: { model: 'test-model' },
messages: prompts,
isChatModel: false,
});
expect(executionFunctions.addInputData).toHaveBeenCalledWith(
'ai_languageModel',
@@ -13,7 +13,7 @@ dependencies = [
sentry = ["sentry-sdk>=2.35.2"]
[tool.uv]
constraint-dependencies = ["urllib3>=2.7.0", "certifi>=2026.10.19"]
constraint-dependencies = ["urllib3>=2.7.0", "certifi>=2026.10.23"]
[dependency-groups]
dev = [
+4 -4
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.13"
[manifest]
constraints = [
{ name = "certifi", specifier = ">=2026.10.19" },
{ name = "certifi", specifier = ">=2026.10.23" },
{ name = "urllib3", specifier = ">=2.7.0" },
]
@@ -74,11 +74,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.19"
version = "2026.10.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
]
[[package]]
@@ -271,10 +271,16 @@ describe('InstanceAiModelService', () => {
await expect(
service.resolveMemoryModelConfig(
fakeUser,
{ id: 'anthropic/claude-opus-5-5', url: 'https://anthropic.example', apiKey: 'main-key' },
{
kind: 'endpoint',
id: 'anthropic/claude-opus-5-5',
url: 'https://anthropic.example',
apiKey: 'main-key',
},
{},
),
).resolves.toEqual({
kind: 'endpoint',
id: 'anthropic/claude-haiku-4-5',
url: 'https://anthropic.example',
apiKey: 'main-key',
@@ -1390,6 +1390,7 @@ describe('InstanceAiSettingsService', () => {
);
expect(result).toEqual({
kind: 'endpoint',
id: 'openai/gpt-4.1',
url: '',
apiKey: 'admin-key',
@@ -1438,6 +1439,7 @@ describe('InstanceAiSettingsService', () => {
await service.updateAdminSettings({ modelCredentialId: credential.id, modelName });
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
kind: 'endpoint',
...expected,
url,
apiKey: 'admin-key',
@@ -1457,6 +1459,7 @@ describe('InstanceAiSettingsService', () => {
});
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
kind: 'endpoint',
id: 'openai/gpt-5',
url: '',
apiKey: 'environment-key',
@@ -1484,6 +1487,7 @@ describe('InstanceAiSettingsService', () => {
});
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
kind: 'endpoint',
id: 'openai/model-y',
url: '',
apiKey: 'updated-key',
@@ -1518,7 +1522,7 @@ describe('InstanceAiSettingsService', () => {
},
}),
),
).resolves.toEqual({ id: 'openai/gpt-user', url: '', apiKey: 'user-key' });
).resolves.toEqual({ kind: 'endpoint', id: 'openai/gpt-user', url: '', apiKey: 'user-key' });
});
it('falls back to the user credential when the admin credential data is incomplete', async () => {
@@ -1550,7 +1554,7 @@ describe('InstanceAiSettingsService', () => {
},
}),
),
).resolves.toEqual({ id: 'openai/gpt-user', url: '', apiKey: 'user-key' });
).resolves.toEqual({ kind: 'endpoint', id: 'openai/gpt-user', url: '', apiKey: 'user-key' });
expect(logger.warn).toHaveBeenCalledWith(
'Could not resolve the configured model credential; using environment fallback',
{
@@ -1584,7 +1588,7 @@ describe('InstanceAiSettingsService', () => {
},
}),
),
).resolves.toEqual({ id: 'openai/gpt-user', url: '', apiKey: 'user-key' });
).resolves.toEqual({ kind: 'endpoint', id: 'openai/gpt-user', url: '', apiKey: 'user-key' });
expect(logger.warn).toHaveBeenCalledWith(
'Could not resolve the configured model credential; using environment fallback',
{ credentialUseId: 'instance-ai:model', error: 'gone' },
@@ -1611,7 +1615,7 @@ describe('InstanceAiSettingsService', () => {
},
}),
),
).resolves.toEqual({ id: 'openai/gpt-user', url: '', apiKey: 'user-key' });
).resolves.toEqual({ kind: 'endpoint', id: 'openai/gpt-user', url: '', apiKey: 'user-key' });
expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled();
});
it('reads the configured model credential from the broker', async () => {
@@ -1955,19 +1959,29 @@ describe('InstanceAiSettingsService', () => {
['openai/original', 'replacement', 'openai/replacement'],
['original', 'replacement', 'custom/replacement'],
[
{ id: 'anthropic/original', url: '', apiKey: 'key' },
{ kind: 'endpoint', id: 'anthropic/original', url: '', apiKey: 'key' },
'replacement',
{ id: 'anthropic/replacement', url: '', apiKey: 'key' },
{ kind: 'endpoint', id: 'anthropic/replacement', url: '', apiKey: 'key' },
],
[
{ id: 'original', url: '', apiKey: 'key' },
{ kind: 'endpoint', id: 'original', url: '', apiKey: 'key' },
'replacement',
{ id: 'custom/replacement', url: '', apiKey: 'key' },
{ kind: 'endpoint', id: 'custom/replacement', url: '', apiKey: 'key' },
],
[
{ id: 'google-vertex-anthropic/original', project: 'acme', location: 'us-east5' },
{
kind: 'vertex-anthropic',
id: 'google-vertex-anthropic/original',
project: 'acme',
location: 'us-east5',
},
'replacement',
{ id: 'google-vertex-anthropic/replacement', project: 'acme', location: 'us-east5' },
{
kind: 'vertex-anthropic',
id: 'google-vertex-anthropic/replacement',
project: 'acme',
location: 'us-east5',
},
],
] as const)(
'replaces the selected model while keeping its provider',
@@ -2002,7 +2016,7 @@ describe('InstanceAiSettingsService', () => {
{ type: 'openAiApi', data: { apiKey: 'key' } },
'gpt-5.4',
),
).toEqual({ id: 'openai/gpt-5.4', url: '', apiKey: 'key' });
).toEqual({ kind: 'endpoint', id: 'openai/gpt-5.4', url: '', apiKey: 'key' });
expect(() =>
service.buildModelConfigForConnection(
@@ -2024,6 +2038,7 @@ describe('InstanceAiSettingsService', () => {
});
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
kind: 'endpoint',
id: 'openai/gpt-4',
url: 'https://model.example.com/v1',
apiKey: 'model-key',
@@ -2031,6 +2046,7 @@ describe('InstanceAiSettingsService', () => {
globalConfig.instanceAi.modelUrl = '';
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
kind: 'endpoint',
id: 'openai/gpt-4',
url: '',
apiKey: 'model-key',
@@ -2053,6 +2069,7 @@ describe('InstanceAiSettingsService', () => {
});
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
kind: 'vertex-anthropic',
id: 'google-vertex-anthropic/claude-opus-4-8',
project: 'instance-ai-494613',
location: 'global',
@@ -2074,6 +2091,7 @@ describe('InstanceAiSettingsService', () => {
});
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
kind: 'vertex-anthropic',
id: 'google-vertex-anthropic/claude-opus-4-8',
project: 'from-json',
location: 'global',
@@ -88,7 +88,12 @@ describe('InstanceAiVerificationService', () => {
it('verifies a draft connection with restored credential data', async () => {
const connection = { type: 'openAiApi', data: { apiKey: '__redacted__' } };
const restored = { type: 'openAiApi', data: { apiKey: 'saved-key' } };
const modelConfig = { id: 'openai/gpt-5.4', url: '', apiKey: 'saved-key' } as const;
const modelConfig = {
kind: 'endpoint',
id: 'openai/gpt-5.4',
url: '',
apiKey: 'saved-key',
} as const;
settingsService.resolveModelConnectionForVerification.mockResolvedValue(restored);
settingsService.buildModelConfigForConnection.mockReturnValue(modelConfig);
@@ -8367,6 +8367,7 @@ describe('createContext — run model wiring', () => {
it('copies the host-resolved modelId onto the context', () => {
const service = createAdapterWithGatewayMock(vi.fn());
const modelId = {
kind: 'endpoint' as const,
id: 'anthropic/claude-opus-4-8' as const,
url: 'https://proxy.example.com/anthropic/v1',
apiKey: 'proxy-token',
@@ -6971,6 +6971,7 @@ describe('createAgentMemoryOptions', () => {
).toBe('anthropic/claude-haiku-4-5');
// The proxy and credential paths return object configs, which must pass through as-is.
const credentialModel: ModelConfig = {
kind: 'endpoint',
id: 'anthropic/claude-haiku-4-5',
url: '',
apiKey: 'main-key',
@@ -7,14 +7,34 @@ describe('withModelName', () => {
['anthropic/claude-opus-5-5', 'claude-haiku-4-5', 'anthropic/claude-haiku-4-5'],
['claude-opus-5-5', 'claude-haiku-4-5', 'custom/claude-haiku-4-5'],
[
{ id: 'anthropic/claude-opus-5-5', url: 'https://anthropic.example', apiKey: 'main-key' },
{
kind: 'endpoint',
id: 'anthropic/claude-opus-5-5',
url: 'https://anthropic.example',
apiKey: 'main-key',
},
'claude-haiku-4-5',
{ id: 'anthropic/claude-haiku-4-5', url: 'https://anthropic.example', apiKey: 'main-key' },
{
kind: 'endpoint',
id: 'anthropic/claude-haiku-4-5',
url: 'https://anthropic.example',
apiKey: 'main-key',
},
],
[
{ id: 'google-vertex-anthropic/claude-opus-5-5', project: 'acme', location: 'us-east5' },
{
kind: 'vertex-anthropic',
id: 'google-vertex-anthropic/claude-opus-5-5',
project: 'acme',
location: 'us-east5',
},
'claude-haiku-4-5',
{ id: 'google-vertex-anthropic/claude-haiku-4-5', project: 'acme', location: 'us-east5' },
{
kind: 'vertex-anthropic',
id: 'google-vertex-anthropic/claude-haiku-4-5',
project: 'acme',
location: 'us-east5',
},
],
])('keeps the provider and credentials of %j', (config, modelName, expected) => {
expect(withModelName(config, modelName)).toEqual(expected);
@@ -44,6 +64,7 @@ describe('withModelName', () => {
'reads the name like the main model env with dropMainProviderPrefix: main %s with %s uses %s',
(mainId, modelName, expectedId) => {
const config: ModelConfig = {
kind: 'endpoint',
id: mainId as `${string}/${string}`,
url: 'https://provider.example',
apiKey: 'main-key',
@@ -68,6 +68,7 @@ describe('runMetricsModelLabel', () => {
it("collapses user-configured endpoints and unknown configs to 'custom'", () => {
expect(
runMetricsModelLabel({
kind: 'endpoint',
id: 'custom/some-self-hosted-model',
url: 'https://llm.example.com/v1',
}),
@@ -9,7 +9,7 @@ import { Logger } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { ModelConfig } from '@n8n/instance-ai';
import { isEndpointModelConfig, type ModelConfig } from '@n8n/instance-ai';
import { getErrorMessage } from '@n8n/utils/errors/get-error-message';
import { nanoid } from 'nanoid';
@@ -177,8 +177,9 @@ export class InstanceAiModelService {
if (!modelId) return undefined;
const { provider, model: modelName } = splitModelId(modelId);
const apiKey = typeof config === 'object' && 'apiKey' in config ? config.apiKey : undefined;
const baseURL = typeof config === 'object' && 'url' in config ? config.url : undefined;
const endpoint = isEndpointModelConfig(config) ? config : undefined;
const apiKey = endpoint?.apiKey;
const baseURL = endpoint?.url;
if (provider !== 'anthropic') return undefined;
const { createAnthropic } = await import('@ai-sdk/anthropic');
@@ -1443,6 +1443,7 @@ export class InstanceAiSettingsService {
if (!baseUrl && !apiKey) return null;
const headers = modelCredentialHeaders(credentialType, data);
return {
kind: 'endpoint',
id,
url: baseUrl,
...(apiKey ? { apiKey } : {}),
@@ -1623,6 +1624,7 @@ export class InstanceAiSettingsService {
if (modelUrl) {
return {
kind: 'endpoint',
id,
url: modelUrl,
...(modelApiKey ? { apiKey: modelApiKey } : {}),
@@ -1632,6 +1634,7 @@ export class InstanceAiSettingsService {
if (modelApiKey) {
return {
kind: 'endpoint',
id,
url: '',
apiKey: modelApiKey,
@@ -1667,6 +1670,7 @@ export class InstanceAiSettingsService {
const googleCredentials = this.config.vertexServiceAccountJson?.trim() || undefined;
return {
kind: 'vertex-anthropic',
id,
project: this.resolveVertexProjectId(),
location: this.resolveVertexLocation(),
@@ -20,14 +20,15 @@ export function withModelName(
const provider = splitModelId(config).provider || 'custom';
return `${provider}/${nameFor(provider)}`;
}
if ('project' in config) {
return { ...config, id: `google-vertex-anthropic/${nameFor('google-vertex-anthropic')}` };
if (!('kind' in config)) return undefined;
switch (config.kind) {
case 'vertex-anthropic':
return { ...config, id: `google-vertex-anthropic/${nameFor('google-vertex-anthropic')}` };
case 'endpoint': {
const provider = splitModelId(config.id).provider || 'custom';
return { ...config, id: `${provider}/${nameFor(provider)}` };
}
}
if ('url' in config) {
const provider = splitModelId(config.id).provider || 'custom';
return { ...config, id: `${provider}/${nameFor(provider)}` };
}
return undefined;
}
/**
@@ -36,7 +36,10 @@ Supply `projectId`, `workflowId`, and the original failed `executionId`.
The producer selects the project configuration and checks the enabling user’s current access.
A selected-workflow configuration takes precedence over all-workflow coverage, even when paused.
The call returns `null` when no enabled configuration authorizes the investigation.
It also returns `null` for a new failure during a Could not fix cooldown. See [Investigate again](#investigate-again).
It captures the baseline with `WorkflowSuggestionService.captureBaseline()`.
The run keeps the captured baseline in memory. The investigation stores only the published version
ID, which the cooldown reads.
The workflow must be fully published. The failure must belong to its current published version.
Manual and evaluation executions are ineligible.
Credential and network failures can still need investigation.
@@ -121,17 +124,29 @@ There is no public launch endpoint.
## Investigate again
Admission uses the same Open and Closed states as Inbox. An undismissed result stays open
when it has no suggestion or its suggestion is pending. This includes Fix ready,
Needs attention, and Could not fix reports.
Admission blocks new investigations while a Fix ready or Needs attention result is Open.
An undismissed Fix ready or Needs attention result stays Open when it has no suggestion or its
suggestion is pending. Could not fix results are Closed immediately and return the `closed`
review state. Automatic closure leaves `dismissedAt` and `dismissedById` empty.
Dismissal, discard, or outdated reconciliation permits another eligible production failure
on the same published version. Apply saves a draft. Another investigation requires successful
publication and a new eligible failure. Closing a result, opening the editor, changing a
configuration, and publishing do not start an investigation.
For a Fix ready or Needs attention result, dismissal, discard, or outdated reconciliation permits
another eligible production failure on the same published version. Apply saves a draft.
Another investigation requires successful publication and a new eligible failure. Closing a
result, opening the editor, changing a configuration, and publishing do not start an investigation.
A report without a suggestion stays open until dismissed, even after a new publication.
Pause, resume, and configuration edits do not clear this block. Configuration deletion removes
A Needs attention report without a suggestion stays Open until dismissed, even after a new publication.
A Could not fix result blocks investigations for 24 hours after its completion. The block applies
to the workflow, its owning project, and the investigated published version. Admission checks
the workflow's current published version before it captures a baseline. A saved draft does not
change that version, so admission skips the failure without an error. The claim transaction checks
again after it confirms that the captured version is still published. Skipped failures do not
extend the cooldown.
Another accepted Could not fix result starts a new cooldown. A different successfully published
version becomes eligible immediately. Both cases require another eligible production failure.
Republishing the same version does not reset the cooldown. No background job clears it.
Pause, resume, and configuration edits do not clear either block. Configuration deletion removes
its history and resets the block. Results from a previous owning project do not block the new project.
Crashes, interruptions, and turn limits without an accepted result release the running claim.
@@ -199,8 +214,9 @@ It keeps both fields within their output limits after replacement.
It passes the report, runtime usage, and compiled graph directly to the result service.
A credit-claim failure leaves unknown credits and does not discard valid output.
`SelfHealingResultService.complete(...)` prepares the optional suggestion through
`WorkflowSuggestionService`. It rechecks access and the captured baseline, then saves the result,
`SelfHealingResultService.complete(...)` receives the captured baseline from the run and prepares
the optional suggestion through `WorkflowSuggestionService`. It rejects a baseline from another
investigation. It rechecks access and the captured baseline, then saves the result,
suggestion, submission activity, and investigation completion in one transaction.
Cancellation before acceptance rolls back the result and suggestion.
Once the completion commits, a later cancellation does not change it to a canceled run.
@@ -225,7 +241,7 @@ It does not remove arbitrary private information.
| --------------- | ---------- | -------------------------------------------- |
| `fix_ready` | Required | Approve and publish, Open in editor, Discard |
| `needs_you` | Optional | Continue in chat, Dismiss |
| `could_not_fix` | None | Continue in chat, Dismiss |
| `could_not_fix` | None | Continue in chat |
The summary is a plain-language title with at most 80 characters.
The agent writes the report as one Markdown string with two sections: **What happened**
@@ -273,7 +289,9 @@ check the current workflow before writing. If Apply fails, keep the review open
Both Discard and Dismiss buttons call `POST /:resultId/dismiss`.
Dismissal discards an attached pending suggestion. Fix-ready results derive their discarded state
from the suggestion; informational results also record dismissal in the same transaction.
from the suggestion. Needs attention results also record dismissal in the same transaction.
Dismissal leaves automatically closed Could not fix results unchanged and returns `closed`.
A dismissal recorded before automatic closure still returns `dismissed`.
Already applied, discarded, or outdated suggestions keep their existing closure.
Apply uses the existing suggestion action service. An applied result stays applied if publication
returns an error. `publishError` describes that request only. The response includes the workflow
@@ -299,5 +317,6 @@ The shared Inbox reads result rows through `SelfHealingResultService.listForInbo
It reads counts through `countForInbox()`. Both use current reviewer membership, editor access, and original-project ownership.
The source selects rows before applying the shared pagination boundary and limit.
The shared API uses `GET /inbox` and `GET /inbox/summary`.
Open and Closed derive from result dismissal and suggestion closure. Inbox reads do not change either state.
Open and Closed derive from result outcome, dismissal, and suggestion closure.
Could not fix results are Closed. Inbox reads do not change either state.
The review UI will provide review screens and chat and editor navigation.
@@ -99,7 +99,7 @@ async function createInvestigation(
executionId: randomUUID(),
threadId: randomUUID(),
runId: randomUUID(),
baseline: {},
publishedVersionId: randomUUID(),
finishedAt,
});
}
@@ -373,7 +373,7 @@ it('reports the investigation time in UTC when the server uses another timezone'
executionId: '1',
threadId: randomUUID(),
runId: randomUUID(),
baseline: {},
publishedVersionId: randomUUID(),
createdAt,
});
@@ -95,17 +95,18 @@ it('removes investigations when their workflow is deleted', async () => {
expect(await investigations.findInvestigation(investigation.id)).toBeNull();
});
it('reverts and reapplies the investigation and configuration schemas', async () => {
it('reverts and reapplies the investigation schemas', async () => {
const db = Container.get(DataSource);
[...postgresMigrations, ...sqliteMigrations].forEach(wrapMigration);
const migration = db.migrations.find(
({ constructor }) => constructor.name === 'CreateSelfHealingInvestigationTable1792578704944',
);
if (!migration) throw new Error('The investigation migration is not registered.');
const configurationMigration = db.migrations.find(
({ constructor }) => constructor.name === 'CreateSelfHealingConfigurations1792742505678',
);
if (!configurationMigration) throw new Error('The configuration migration is not registered.');
const migrations = [
'CreateSelfHealingInvestigationTable1792578704944',
'CreateSelfHealingConfigurations1792742505678',
'ReplaceSelfHealingInvestigationBaseline1792750863544',
].map((name) => {
const migration = db.migrations.find(({ constructor }) => constructor.name === name);
if (!migration) throw new Error(`${name} is not registered.`);
return migration;
});
async function runMigration(migration: MigrationInterface, direction: 'up' | 'down') {
// Table rebuilds can leave stale schema metadata on a reused query runner.
const runner = db.createQueryRunner();
@@ -116,26 +117,25 @@ it('reverts and reapplies the investigation and configuration schemas', async ()
}
}
await runMigration(configurationMigration, 'down');
const reverted: typeof migrations = [];
try {
await runMigration(migration, 'down');
for (const migration of [...migrations].reverse()) {
await runMigration(migration, 'down');
reverted.unshift(migration);
}
const runner = db.createQueryRunner();
try {
const runner = db.createQueryRunner();
try {
expect(await runner.hasTable(investigations.metadata.tablePath)).toBe(false);
} finally {
await runner.release();
}
expect(await runner.hasTable(investigations.metadata.tablePath)).toBe(false);
} finally {
await runMigration(migration, 'up');
await runner.release();
}
} finally {
await runMigration(configurationMigration, 'up');
for (const migration of reverted) await runMigration(migration, 'up');
}
configurationId = (await createConfiguration(baseline.projectId, baseline.backgroundUserId)).id;
const investigation = await createInvestigation();
expect(await investigations.findInvestigation(investigation.id)).toMatchObject({
baseline,
publishedVersionId: baseline.expectedBaseline.publishedVersionId,
resultId: null,
finishedAt: null,
});
@@ -95,7 +95,6 @@ describe('SelfHealingInvestigationService', () => {
...input,
id: 'investigation',
configurationId: configuration.id,
baseline,
threadId: 'private-thread',
runId: 'run',
resultId: null,
@@ -123,6 +122,7 @@ describe('SelfHealingInvestigationService', () => {
investigations.getUnfinishedForWorkflow.mockResolvedValue(null);
investigations.createInvestigation.mockResolvedValue(investigation);
resultRepository.getOpenForWorkflow.mockResolvedValue(null);
resultRepository.isInCooldown.mockResolvedValue(false);
txRunner.run.mockImplementation(async (ctx, fn) => await fn(ctx));
runtime.startSelfHealingRun.mockImplementation(async (_input, lifecycle) => {
execution = (async () => {
@@ -167,6 +167,7 @@ describe('SelfHealingInvestigationService', () => {
expect(results.complete).toHaveBeenCalledWith(
expect.objectContaining({
investigationId: investigation.id,
baseline,
output: run.output,
usage: run.usage,
}),
@@ -444,6 +445,55 @@ describe('SelfHealingInvestigationService', () => {
expect(runtime.startSelfHealingRun).not.toHaveBeenCalled();
});
it('skips a cooldown before capturing a baseline or opening the claim', async () => {
resultRepository.isInCooldown.mockResolvedValue(true);
await expect(service.start(input)).resolves.toBeNull();
expect(resultRepository.isInCooldown).toHaveBeenCalledWith({
workflowId: input.workflowId,
projectId: input.projectId,
completedAfter: expect.any(Date),
});
expect(suggestions.captureBaseline).not.toHaveBeenCalled();
expect(executions.validateFailure).not.toHaveBeenCalled();
expect(suggestions.readWorkflowTargetForApply).not.toHaveBeenCalled();
expect(investigations.createInvestigation).not.toHaveBeenCalled();
});
it('returns existing work for the same execution during a cooldown', async () => {
investigations.getForExecution.mockResolvedValue(investigation);
resultRepository.isInCooldown.mockResolvedValue(true);
await expect(service.start(input)).resolves.toEqual({
kind: 'investigation',
investigationId: investigation.id,
});
expect(resultRepository.isInCooldown).not.toHaveBeenCalled();
});
it('checks a newly completed cannot-fix result inside the locked claim transaction', async () => {
const ctx: OperationContext = { trx: mock<Transaction>() };
txRunner.run.mockImplementation(async (_ctx, fn) => await fn(ctx));
suggestions.readWorkflowTargetForApply.mockImplementationOnce(async () => {
resultRepository.isInCooldown.mockResolvedValue(true);
return mock<Awaited<ReturnType<WorkflowSuggestionService['readWorkflowTargetForApply']>>>();
});
await expect(service.start(input)).resolves.toBeNull();
expect(resultRepository.isInCooldown).toHaveBeenLastCalledWith(
{
workflowId: input.workflowId,
projectId: input.projectId,
completedAfter: expect.any(Date),
},
ctx,
);
expect(investigations.createInvestigation).not.toHaveBeenCalled();
expect(runtime.startSelfHealingRun).not.toHaveBeenCalled();
});
it('checks current access before returning existing work', async () => {
investigations.getForExecution.mockResolvedValue(investigation);
configurationService.resolveActorOrPause.mockResolvedValue(null);
@@ -16,8 +16,10 @@ import {
TransactionRunner,
UserRepository,
WorkflowHistoryRepository,
WorkflowPublicationOutboxRepository,
WorkflowPublicationTriggerStatusRepository,
WorkflowRepository,
type WorkflowEntity,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
@@ -46,11 +48,11 @@ import { SelfHealingResultRepository } from '../database/self-healing-result.rep
import { SelfHealingInvestigationRepository } from '../database/self-healing-investigation.repository';
import type { SelfHealingRunResult } from '../self-healing-agent.service';
import { SelfHealingExecutionReferenceService } from '../self-healing-execution-reference.service';
import { SelfHealingInvestigationService } from '../self-healing-investigation.service';
import {
SelfHealingResultService,
type SelfHealingResultInput,
} from '../self-healing-result.service';
SELF_HEALING_CANNOT_FIX_COOLDOWN_MS,
SelfHealingInvestigationService,
} from '../self-healing-investigation.service';
import { SelfHealingResultService } from '../self-healing-result.service';
import { SelfHealingConfigurationService } from '../self-healing-configuration.service';
import { SelfHealingConfigurationRepository } from '../database/self-healing-configuration.repository';
import { SelfHealingConfigurationReviewer } from '../database/self-healing-configuration-reviewer.entity';
@@ -118,9 +120,12 @@ async function completeResult(input: Parameters<SelfHealingResultService['comple
type Outcome = 'fix_ready' | 'needs_you' | 'could_not_fix';
async function prepareFixture(outcome: Outcome = 'needs_you') {
async function prepareFixture(
outcome: Outcome = 'needs_you',
workflowData: Partial<WorkflowEntity> = {},
) {
const user = await createUser();
const workflow = await createWorkflowWithHistory({}, user);
const workflow = await createWorkflowWithHistory(workflowData, user);
const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(user.id);
await workflows.update(workflow.id, { activeVersionId: workflow.versionId, active: true });
await Container.get(WorkflowPublicationTriggerStatusRepository).replaceForWorkflow(workflow.id, [
@@ -160,8 +165,12 @@ async function prepareFixture(outcome: Outcome = 'needs_you') {
return { user, project, original, graph, input };
}
async function fixture(outcome: Outcome = 'needs_you', withSuggestion = false) {
const prepared = await prepareInvestigation(outcome, withSuggestion);
async function fixture(
outcome: Outcome = 'needs_you',
withSuggestion = false,
workflowData: Partial<WorkflowEntity> = {},
) {
const prepared = await prepareInvestigation(outcome, withSuggestion, { workflowData });
const result = await completeResult({
investigationId: prepared.investigation.id,
...prepared.completion,
@@ -176,15 +185,20 @@ async function fixture(outcome: Outcome = 'needs_you', withSuggestion = false) {
async function prepareInvestigation(
outcome: Outcome = 'fix_ready',
withSuggestion: boolean | 'unchanged' = true,
content: { report?: string; usage?: SelfHealingResultUsage } = {},
content: {
report?: string;
usage?: SelfHealingResultUsage;
workflowData?: Partial<WorkflowEntity>;
} = {},
) {
const fixture = await prepareFixture(outcome);
const { workflowData, ...resultContent } = content;
const fixture = await prepareFixture(outcome, workflowData);
const { original, user } = fixture;
const graph =
withSuggestion === 'unchanged'
? { nodes: original.nodes, connections: original.connections }
: fixture.graph;
const input = { ...fixture.input, ...content };
const input = { ...fixture.input, ...resultContent };
const baseline = await suggestionService.captureBaseline(original.id, user.id);
const investigation = await Container.get(TransactionRunner).run({}, async (ctx) => {
return await investigations.createInvestigation(
@@ -198,7 +212,8 @@ async function prepareInvestigation(
ctx,
);
});
const completion: SelfHealingResultInput = {
const completion: Omit<Parameters<SelfHealingResultService['complete']>[0], 'investigationId'> = {
baseline,
output: {
outcome,
summary: input.summary,
@@ -209,7 +224,7 @@ async function prepareInvestigation(
completedAt: new Date().toISOString(),
...(withSuggestion ? { candidate: { graph } } : {}),
};
return { ...fixture, graph, input, investigation, completion };
return { ...fixture, graph, input, baseline, investigation, completion };
}
it.each(['same', 'different'] as const)(
@@ -283,8 +298,34 @@ describe('investigation eligibility after a result', () => {
return { ...input, executionId: execution.id };
}
async function createHistoryVersion(workflow: WorkflowEntity) {
const versionId = randomUUID();
await Container.get(WorkflowHistoryRepository).insert({
workflowId: workflow.id,
versionId,
nodes: workflow.nodes,
connections: workflow.connections,
authors: 'Test',
});
return versionId;
}
async function markTriggerActivated(workflow: WorkflowEntity, versionId: string) {
await Container.get(WorkflowPublicationTriggerStatusRepository).replaceForWorkflow(
workflow.id,
[
{
nodeId: workflow.nodes[0].id,
versionId,
status: 'activated',
triggerKind: 'persisted',
errorMessage: null,
},
],
);
}
it.each([
['could_not_fix', false],
['needs_you', false],
['needs_you', true],
['fix_ready', true],
@@ -313,40 +354,154 @@ describe('investigation eligibility after a result', () => {
},
);
it.each(['could_not_fix', 'fix_ready'] as const)(
'keeps only informational results open after a different version is published: %s',
async (outcome) => {
const { input, result, original } = await fixture(outcome, outcome === 'fix_ready');
const versionId = randomUUID();
await Container.get(WorkflowHistoryRepository).insert({
workflowId: original.id,
versionId,
nodes: original.nodes,
connections: original.connections,
authors: 'Test',
it('skips failures until 24 hours without extending the cooldown or using credits', async () => {
const { input, result, user, project, original } = await fixture('could_not_fix');
const producer = Container.get(SelfHealingInvestigationService);
const expiresAt = result.completedAt.getTime() + SELF_HEALING_CANNOT_FIX_COOLDOWN_MS;
const now = vi.spyOn(Date, 'now').mockReturnValue(expiresAt - 60_000);
await expect(producer.start(await nextFailure(input))).resolves.toBeNull();
await service.dismiss(user, project.id, original.id, result.id);
now.mockReturnValue(expiresAt - 1);
await expect(producer.start(await nextFailure(input))).resolves.toBeNull();
expect(await results.findOneByOrFail({ id: result.id })).toEqual(result);
expect(await investigations.count()).toBe(1);
expect(await results.count()).toBe(1);
expect(runtime.startSelfHealingRun).not.toHaveBeenCalled();
now.mockReturnValue(expiresAt);
await expect(producer.start(await nextFailure(input))).resolves.toMatchObject({
kind: 'investigation',
});
expect(runtime.startSelfHealingRun).toHaveBeenCalledTimes(1);
});
it('starts a new cooldown from the latest accepted cannot-fix result', async () => {
const { input, result, completion } = await fixture('could_not_fix');
const producer = Container.get(SelfHealingInvestigationService);
const expiresAt = result.completedAt.getTime() + SELF_HEALING_CANNOT_FIX_COOLDOWN_MS;
const now = vi.spyOn(Date, 'now').mockReturnValue(expiresAt);
const admitted = await producer.start(await nextFailure(input));
if (admitted?.kind !== 'investigation') throw new Error('Expected a new investigation.');
const completedAt = expiresAt + 60_000;
await completeResult({
investigationId: admitted.investigationId,
...completion,
completedAt: new Date(completedAt).toISOString(),
});
const nextExpiresAt = completedAt + SELF_HEALING_CANNOT_FIX_COOLDOWN_MS;
now.mockReturnValue(nextExpiresAt - 1);
await expect(producer.start(await nextFailure(input))).resolves.toBeNull();
expect(runtime.startSelfHealingRun).toHaveBeenCalledTimes(1);
now.mockReturnValue(nextExpiresAt);
await expect(producer.start(await nextFailure(input))).resolves.toMatchObject({
kind: 'investigation',
});
expect(runtime.startSelfHealingRun).toHaveBeenCalledTimes(2);
});
it.each(['before expiry', 'at expiry', 'new publication'] as const)(
'checks cooldowns for a workflow with escaped characters in pin data: %s',
async (scenario) => {
const { input, result, original } = await fixture('could_not_fix', false, {
pinData: { 'Schedule Trigger': [{ json: { value: 'before\u0000after' } }] },
});
await workflows.update(original.id, { versionId, activeVersionId: versionId });
await Container.get(WorkflowPublicationTriggerStatusRepository).replaceForWorkflow(
original.id,
[
{
nodeId: original.nodes[0].id,
versionId,
status: 'activated',
triggerKind: 'persisted',
errorMessage: null,
},
],
const elapsed = scenario === 'at expiry' ? SELF_HEALING_CANNOT_FIX_COOLDOWN_MS : 60_000;
vi.spyOn(Date, 'now').mockReturnValue(result.completedAt.getTime() + elapsed);
if (scenario === 'new publication') {
const versionId = await createHistoryVersion(original);
await workflows.update(original.id, { versionId, activeVersionId: versionId, pinData: {} });
await markTriggerActivated(original, versionId);
}
const response = await Container.get(SelfHealingInvestigationService).start(
await nextFailure(input),
);
const failure = await nextFailure(input);
const response = await Container.get(SelfHealingInvestigationService).start(failure);
if (outcome === 'could_not_fix') {
expect(response).toEqual({ kind: 'result', resultId: result.id });
if (scenario === 'before expiry') {
expect(response).toBeNull();
expect(runtime.startSelfHealingRun).not.toHaveBeenCalled();
} else {
expect(response?.kind).toBe('investigation');
expect(response).toMatchObject({ kind: 'investigation' });
expect(runtime.startSelfHealingRun).toHaveBeenCalledTimes(1);
}
},
);
it('admits only one concurrent investigation when the cooldown expires', async () => {
const { input, result } = await fixture('could_not_fix');
const producer = Container.get(SelfHealingInvestigationService);
const first = await nextFailure(input);
const second = await nextFailure(input);
vi.spyOn(Date, 'now').mockReturnValue(
result.completedAt.getTime() + SELF_HEALING_CANNOT_FIX_COOLDOWN_MS,
);
const admitted = await Promise.all([producer.start(first), producer.start(second)]);
expect(admitted[0]).toMatchObject({ kind: 'investigation' });
expect(admitted[1]).toEqual(admitted[0]);
expect(runtime.startSelfHealingRun).toHaveBeenCalledTimes(1);
expect(await investigations.count()).toBe(2);
});
it('retains the cooldown after execution pruning and same-version republication', async () => {
const { input, result, user, original } = await fixture('could_not_fix');
await Container.get(ExecutionRepository).delete(input.executionId);
await Container.get(WorkflowService).deactivateWorkflow(user, original.id);
await Container.get(WorkflowService).activateWorkflow(user, original.id, {
versionId: original.versionId,
});
await Container.get(WorkflowPublicationOutboxRepository).update(
{ workflowId: original.id },
{ status: 'completed' },
);
await markTriggerActivated(original, original.versionId);
await expect(
Container.get(SelfHealingInvestigationService).start(await nextFailure(input)),
).resolves.toBeNull();
expect(await results.findOneByOrFail({ id: result.id })).toEqual(result);
expect(runtime.startSelfHealingRun).not.toHaveBeenCalled();
});
it('skips a cooldown failure with a saved draft before capturing a baseline', async () => {
const { input, original } = await fixture('could_not_fix');
const versionId = await createHistoryVersion(original);
await workflows.update(original.id, { versionId });
const captureBaseline = vi.spyOn(suggestionService, 'captureBaseline');
await expect(
Container.get(SelfHealingInvestigationService).start(await nextFailure(input)),
).resolves.toBeNull();
expect(captureBaseline).not.toHaveBeenCalled();
expect(runtime.startSelfHealingRun).not.toHaveBeenCalled();
});
it.each(['could_not_fix', 'fix_ready'] as const)(
'admits a new failure after a different version is published: %s',
async (outcome) => {
const { input, result, original } = await fixture(outcome, outcome === 'fix_ready');
const versionId = await createHistoryVersion(original);
await workflows.update(original.id, { versionId, activeVersionId: versionId });
const producer = Container.get(SelfHealingInvestigationService);
await expect(producer.start(await nextFailure(input))).rejects.toThrow('fully published');
expect(runtime.startSelfHealingRun).not.toHaveBeenCalled();
await markTriggerActivated(original, versionId);
const failure = await nextFailure(input);
const response = await producer.start(failure);
expect(response?.kind).toBe('investigation');
expect(runtime.startSelfHealingRun).toHaveBeenCalledTimes(1);
if (outcome === 'fix_ready') {
const suggestion = await suggestions.findOneByOrFail({ id: result.suggestionId! });
expect(suggestion.closedReason).toBe('outdated');
}
@@ -393,6 +548,7 @@ describe('investigation eligibility after a result', () => {
const { input, user, project, original, result } = await fixture('could_not_fix');
const configurationService = Container.get(SelfHealingConfigurationService);
const producer = Container.get(SelfHealingInvestigationService);
const cooldown = vi.spyOn(results, 'isInCooldown');
await configurationService.setEnabled(user, project.id, input.configurationId, false);
await configurationService.setEnabled(user, project.id, input.configurationId, true);
@@ -409,10 +565,9 @@ describe('investigation eligibility after a result', () => {
);
const failureAfterEdit = await nextFailure(input);
await expect(producer.start(failureAfterEdit)).resolves.toEqual({
kind: 'result',
resultId: result.id,
});
await expect(producer.start(failureAfterEdit)).resolves.toBeNull();
expect(cooldown).toHaveBeenCalledTimes(1);
await expect(cooldown.mock.results[0].value).resolves.toBe(true);
// The all-workflows configuration now covers this workflow instead.
const otherWorkflow = await createWorkflowWithHistory({}, user);
@@ -430,10 +585,9 @@ describe('investigation eligibility after a result', () => {
await createConfiguration(project.id, user.id);
const failureAfterReplacement = await nextFailure(input);
await expect(producer.start(failureAfterReplacement)).resolves.toEqual({
kind: 'result',
resultId: result.id,
});
await expect(producer.start(failureAfterReplacement)).resolves.toBeNull();
expect(cooldown).toHaveBeenCalledTimes(2);
await expect(cooldown.mock.results[1].value).resolves.toBe(true);
await configurationService.remove(project.id, input.configurationId);
@@ -627,6 +781,16 @@ it('rejects a changed baseline when saving a completed report', async () => {
expect((await investigations.findOneByOrFail({ id: investigation.id })).finishedAt).toBeNull();
});
it('rejects a baseline from another investigation', async () => {
const { investigation, completion } = await prepareInvestigation('could_not_fix', false);
const baseline = structuredClone(completion.baseline);
baseline.expectedBaseline.publishedVersionId = randomUUID();
await expect(
service.complete({ investigationId: investigation.id, ...completion, baseline }),
).rejects.toThrow('does not belong');
expect(await results.count()).toBe(0);
});
it('rejects completion after interrupted-run cleanup', async () => {
const { investigation, completion } = await prepareInvestigation('could_not_fix', false);
await investigations.finishWithoutResult(investigation.id);
@@ -661,7 +825,7 @@ it.each([
resultId: result.id,
outcome,
report: input.report,
reviewState: 'open',
reviewState: outcome === 'could_not_fix' ? 'closed' : 'open',
usage: input.usage,
execution: { status: 'available', id: input.executionId },
});
@@ -778,26 +942,39 @@ it('keeps the report and unknown usage after the execution is removed', async ()
});
});
it.each(['needs_you', 'could_not_fix'] as const)(
'dismisses a %s result for assigned editors without changing its workflow',
async (outcome) => {
it.each([
['needs_you', 'dismissed'],
['could_not_fix', 'closed'],
] as const)(
'handles dismissal of %s without changing its workflow',
async (outcome, reviewState) => {
const { user, project, original, result, url } = await fixture(outcome);
const editor = await createUser();
await addReviewer(result.configurationId, editor.id);
await shareWorkflowWithUsers(original, [editor]);
const response = await testServer.authAgentFor(user).post(`${url}/dismiss`);
expect(response.status).toBe(200);
expect(response.body.data.reviewState).toBe('dismissed');
expect(response.body.data.reviewState).toBe(reviewState);
const firstDismissal = await results.findOneByOrFail({ id: result.id });
if (outcome === 'could_not_fix') {
expect(firstDismissal).toEqual({ ...result, dismissedAt: null, dismissedById: null });
}
await service.dismiss(editor, project.id, original.id, result.id);
expect(await results.findOneByOrFail({ id: result.id })).toEqual(firstDismissal);
expect((await service.getDetail(editor, project.id, original.id, result.id)).reviewState).toBe(
'dismissed',
reviewState,
);
expect(await workflows.findOneByOrFail({ id: original.id })).toEqual(original);
},
);
it('returns a recorded dismissal of a could_not_fix result as dismissed', async () => {
const { user, result, getDetail } = await fixture('could_not_fix');
await results.update(result.id, { dismissedAt: new Date(), dismissedById: user.id });
expect(await getDetail()).toMatchObject({ reviewState: 'dismissed', dismissedById: user.id });
});
it('rolls back shared dismissal when result storage fails', async () => {
const { user, project, original, result } = await fixture('needs_you', true);
const suggestionId = result.suggestionId!;
@@ -1143,6 +1320,7 @@ it('returns report details without changing the result or workflow', async () =>
describe('Inbox source reads', () => {
async function addResult({
original,
baseline,
investigation,
completion,
}: Awaited<ReturnType<typeof fixture>>) {
@@ -1155,7 +1333,7 @@ describe('Inbox source reads', () => {
async (ctx) =>
await investigations.createInvestigation(
{
baseline: investigation.baseline,
baseline,
configurationId: investigation.configurationId,
executionId: execution.id,
threadId: randomUUID(),
@@ -1176,12 +1354,14 @@ describe('Inbox source reads', () => {
'lists one compact row for %s with suggestion=%s',
async (outcome, withSuggestion) => {
const { user, original, result, input } = await fixture(outcome, withSuggestion);
const rows = await service.listForInbox(user, { state: 'open', limit: 10 });
const [state, otherState] =
outcome === 'could_not_fix' ? (['closed', 'open'] as const) : (['open', 'closed'] as const);
const rows = await service.listForInbox(user, { state, limit: 10 });
expect(rows).toEqual([
{
type: 'self_healing_result',
id: result.id,
state: 'open',
state,
projectId: result.projectId,
workflowId: original.id,
workflowName: original.name,
@@ -1192,7 +1372,8 @@ describe('Inbox source reads', () => {
completedAt: result.completedAt.toISOString(),
},
]);
expect(await service.countForInbox(user)).toEqual({ open: 1, closed: 0 });
expect(await service.countForInbox(user)).toEqual({ [state]: 1, [otherState]: 0 });
expect(await service.listForInbox(user, { state: otherState, limit: 10 })).toEqual([]);
},
);
@@ -1398,6 +1579,9 @@ describe('configuration lifecycle', () => {
);
const producer = Container.get(SelfHealingInvestigationService);
const failure = { ...input, executionId: nextExecution.id };
vi.spyOn(Date, 'now').mockReturnValue(
result.completedAt.getTime() + SELF_HEALING_CANNOT_FIX_COOLDOWN_MS + 1,
);
expect(await producer.start(failure)).toBeNull();
expect(runtime.startSelfHealingRun).not.toHaveBeenCalled();
@@ -1422,7 +1606,8 @@ describe('configuration lifecycle', () => {
resultId: result.id,
});
await configurations.setEnabled(user, project.id, result.configurationId, true);
expect(await producer.start(failure)).toEqual({ kind: 'result', resultId: result.id });
expect(await producer.start(failure)).toMatchObject({ kind: 'investigation' });
expect(runtime.startSelfHealingRun).toHaveBeenCalledTimes(1);
});
it('pauses when the sole reviewer is deleted between admission and claim', async () => {
@@ -1,10 +1,9 @@
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import { RedisClientService } from '@n8n/backend-services';
import { ExecutionsConfig, GlobalConfig, InstanceAiConfig } from '@n8n/config';
import { SharedWorkflowRepository, type Project } from '@n8n/db';
import { SharedWorkflowRepository } from '@n8n/db';
import { PubSubMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import type { InstanceSettings } from 'n8n-core';
import { Redis } from 'ioredis';
import { mock } from 'vitest-mock-extended';
@@ -38,6 +37,7 @@ describe.skipIf(!host || !port)('self-healing failure relay (real Redis)', () =>
let publisher: Publisher;
let subscriber: Subscriber;
let workerTrigger: SelfHealingTriggerService;
let mainTrigger: SelfHealingTriggerService;
let bus: PubSubEventBus;
beforeAll(async () => {
@@ -46,13 +46,12 @@ describe.skipIf(!host || !port)('self-healing failure relay (real Redis)', () =>
bus = new PubSubEventBus();
subscriber = new Subscriber(logger, main, bus, redis, executionsConfig, globalConfig);
const mainTrigger = new SelfHealingTriggerService(config, main, publisher, ownership, logger);
mainTrigger = new SelfHealingTriggerService(config, main, publisher, ownership, logger);
Container.set(SelfHealingTriggerService, mainTrigger);
const registry = new PubSubRegistry(logger, main, Container.get(PubSubMetadata), bus);
registry.init();
workerTrigger = new SelfHealingTriggerService(config, worker, publisher, ownership, logger);
ownership.getWorkflowOwningProject.mockResolvedValue(mock<Project>({ id: 'project' }));
await subscriber.subscribe(subscriber.getCommandChannel());
});
@@ -64,27 +63,24 @@ describe.skipIf(!host || !port)('self-healing failure relay (real Redis)', () =>
});
it('delivers consecutive failures to main without debounce or a worker-side start', async () => {
const delivered = createDeferredPromise();
investigations.start.mockResolvedValueOnce(null).mockImplementationOnce(async () => {
delivered.resolve();
return null;
});
// Test the Redis relay here. Unit tests cover investigation admission on main.
const handleFailure = vi.spyOn(mainTrigger, 'handleFailure').mockResolvedValue();
await workerTrigger.handleFailure({ workflowId: 'workflow-1', executionId: '1' });
await workerTrigger.handleFailure({ workflowId: 'workflow-2', executionId: '2' });
await Promise.all([
workerTrigger.handleFailure({ workflowId: 'workflow-1', executionId: '1' }),
workerTrigger.handleFailure({ workflowId: 'workflow-2', executionId: '2' }),
]);
// Use the test timeout for Redis delivery instead of a one-second polling limit.
await delivered.promise;
expect(investigations.start).toHaveBeenCalledTimes(2);
expect(investigations.start).toHaveBeenCalledWith({
await vi.waitFor(() => expect(handleFailure).toHaveBeenCalledTimes(2), { timeout: 5000 });
expect(handleFailure).toHaveBeenNthCalledWith(1, {
workflowId: 'workflow-1',
executionId: '1',
projectId: 'project',
});
expect(investigations.start).toHaveBeenCalledWith({
expect(handleFailure).toHaveBeenNthCalledWith(2, {
workflowId: 'workflow-2',
executionId: '2',
projectId: 'project',
});
expect(ownership.getWorkflowOwningProject).not.toHaveBeenCalled();
expect(investigations.start).not.toHaveBeenCalled();
});
});
@@ -1,12 +1,4 @@
import type { WorkflowSuggestionBaseline } from '@n8n/api-types';
import {
DateTimeColumn,
JsonColumn,
Project,
User,
WorkflowEntity,
WithTimestampsAndStringId,
} from '@n8n/db';
import { DateTimeColumn, Project, User, WorkflowEntity, WithTimestampsAndStringId } from '@n8n/db';
import { Column, Entity, Index, JoinColumn, ManyToOne, type Relation } from '@n8n/typeorm';
import { SelfHealingResult } from './self-healing-result.entity';
@@ -58,8 +50,8 @@ export class SelfHealingInvestigation extends WithTimestampsAndStringId {
@Column({ type: 'uuid' })
runId: string;
@JsonColumn()
baseline: WorkflowSuggestionBaseline;
@Column({ type: 'varchar', length: 36 })
publishedVersionId: string;
@Column({ type: 'varchar', length: 36, nullable: true })
resultId: string | null;
@@ -48,13 +48,15 @@ export class SelfHealingInvestigationRepository extends BaseRepository<SelfHeali
},
ctx: OperationContext,
) {
const { baseline, ...fields } = input;
const manager = this.managerFor(ctx);
return await manager.save(
manager.create(SelfHealingInvestigation, {
...input,
workflowId: input.baseline.workflowId,
projectId: input.baseline.projectId,
backgroundUserId: input.baseline.backgroundUserId,
...fields,
workflowId: baseline.workflowId,
projectId: baseline.projectId,
backgroundUserId: baseline.backgroundUserId,
publishedVersionId: baseline.expectedBaseline.publishedVersionId,
resultId: null,
finishedAt: null,
}),
@@ -13,9 +13,12 @@ import type { Scope } from '@n8n/permissions';
import { DataSource, IsNull } from '@n8n/typeorm';
import { SelfHealingConfigurationReviewer } from './self-healing-configuration-reviewer.entity';
import { SelfHealingInvestigation } from './self-healing-investigation.entity';
import { SelfHealingResult } from './self-healing-result.entity';
// Keep in sync with getSelfHealingReviewState(), which returns 'open' for the same rows.
const inboxState = `CASE WHEN result.dismissedAt IS NULL
AND result.outcome <> 'could_not_fix'
AND (result.suggestionId IS NULL OR suggestion.state = 'pending')
THEN 'open' ELSE 'closed' END`;
@@ -52,6 +55,12 @@ type SelfHealingInboxQuery = {
boundary?: SelfHealingInboxBoundary;
};
type CooldownQuery = {
workflowId: string;
projectId: string;
completedAfter: Date;
};
@Service()
export class SelfHealingResultRepository extends BaseRepository<SelfHealingResult> {
constructor(
@@ -93,6 +102,20 @@ export class SelfHealingResultRepository extends BaseRepository<SelfHealingResul
.getOne();
}
async isInCooldown(
{ workflowId, projectId, completedAfter }: CooldownQuery,
ctx: OperationContext = {},
): Promise<boolean> {
return await this.managerFor(ctx)
.createQueryBuilder(SelfHealingResult, 'result')
.innerJoin(SelfHealingInvestigation, 'investigation', 'investigation.resultId = result.id')
.innerJoin('result.workflow', 'workflow')
.where({ workflowId, projectId, outcome: 'could_not_fix' })
.andWhere('result.completedAt > :completedAfter', { completedAfter })
.andWhere('investigation.publishedVersionId = workflow.activeVersionId')
.getExists();
}
async listForInbox(
access: SelfHealingInboxAccess,
{ state, limit, boundary }: SelfHealingInboxQuery,
@@ -1,6 +1,7 @@
import type { WorkflowSuggestionBaseline } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { InstanceAiConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import {
SharedWorkflowRepository,
TransactionRunner,
@@ -30,6 +31,8 @@ import {
type SelfHealingResultInput,
} from './self-healing-result.service';
export const SELF_HEALING_CANNOT_FIX_COOLDOWN_MS = Time.days.toMilliseconds;
type StartInvestigation = {
workflowId: string;
projectId: string;
@@ -90,6 +93,8 @@ export class SelfHealingInvestigationService {
const existing = await this.findExisting(input);
if (existing) return existing;
if (await this.resultRepository.isInCooldown(this.cooldownQuery(input))) return null;
try {
const baseline = await this.suggestions.captureBaseline(input.workflowId, actor.id);
if (baseline.projectId !== input.projectId) {
@@ -101,7 +106,7 @@ export class SelfHealingInvestigationService {
if (!claimed || 'kind' in claimed) return claimed;
// Return the investigation ID without waiting for the agent to finish.
void this.run(claimed, actor).catch((error: unknown) => {
void this.run(claimed, baseline, actor).catch((error: unknown) => {
this.logger.error('Self-healing investigation failed.', {
investigationId: claimed.id,
error,
@@ -175,6 +180,8 @@ export class SelfHealingInvestigationService {
if (existing) return existing;
await this.suggestions.requireCurrentBaseline(baseline, ctx);
if (await this.resultRepository.isInCooldown(this.cooldownQuery(input), ctx)) return null;
return await this.investigations.createInvestigation(
{
baseline,
@@ -188,19 +195,28 @@ export class SelfHealingInvestigationService {
});
}
private async run(investigation: SelfHealingInvestigation, user: User) {
private cooldownQuery({ workflowId, projectId }: StartInvestigation) {
const completedAfter = new Date(Date.now() - SELF_HEALING_CANNOT_FIX_COOLDOWN_MS);
return { workflowId, projectId, completedAfter };
}
private async run(
investigation: SelfHealingInvestigation,
baseline: WorkflowSuggestionBaseline,
user: User,
) {
try {
await this.runtime.startSelfHealingRun(
{
user,
baseline: investigation.baseline,
baseline,
executionId: investigation.executionId,
},
{
threadId: investigation.threadId,
runId: investigation.runId,
onComplete: async (run, signal) =>
await this.handleRunCompletion(investigation, run, signal),
await this.handleRunCompletion(investigation, baseline, run, signal),
},
);
} catch (error) {
@@ -219,6 +235,7 @@ export class SelfHealingInvestigationService {
private async handleRunCompletion(
investigation: SelfHealingInvestigation,
baseline: WorkflowSuggestionBaseline,
run: SelfHealingRunResult,
signal: AbortSignal,
) {
@@ -286,9 +303,9 @@ export class SelfHealingInvestigationService {
),
};
await this.suggestions.requireCurrentBaseline(investigation.baseline);
await this.suggestions.requireCurrentBaseline(baseline);
signal.throwIfAborted();
await this.results.complete({ investigationId: investigation.id, ...result, signal });
await this.results.complete({ investigationId: investigation.id, baseline, ...result, signal });
}
async reconcile(signal?: AbortSignal) {
@@ -8,6 +8,7 @@ import {
type SelfHealingResultActionResponse,
type WorkflowSuggestionActionResult,
type WorkflowSuggestionAppliedVersion,
type WorkflowSuggestionBaseline,
type WorkflowSuggestionProposalDetail,
} from '@n8n/api-types';
import { RoleService } from '@n8n/backend-services';
@@ -20,7 +21,7 @@ import {
} from '@n8n/db';
import { Service } from '@n8n/di';
import type { SelfHealingOutput } from '@n8n/instance-ai';
import { ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors';
import { ConflictError, ForbiddenError, NotFoundError, UnexpectedError } from '@n8n/errors';
import type { Scope } from '@n8n/permissions';
import isEqual from 'lodash/isEqual';
@@ -48,10 +49,12 @@ export type SelfHealingResultInput = {
};
export function getSelfHealingReviewState(
result: Pick<SelfHealingResult, 'dismissedAt'>,
result: Pick<SelfHealingResult, 'dismissedAt' | 'outcome'>,
suggestion: Pick<WorkflowSuggestionProposalDetail, 'closedReason'> | null,
): SelfHealingResultDetail['reviewState'] {
return result.dismissedAt ? 'dismissed' : (suggestion?.closedReason ?? 'open');
if (result.dismissedAt) return 'dismissed';
if (result.outcome === 'could_not_fix') return 'closed';
return suggestion?.closedReason ?? 'open';
}
@Service()
@@ -103,21 +106,34 @@ export class SelfHealingResultService {
async complete({
investigationId,
baseline,
signal,
...input
}: SelfHealingResultInput & { investigationId: string; signal?: AbortSignal }) {
}: SelfHealingResultInput & {
investigationId: string;
baseline: WorkflowSuggestionBaseline;
signal?: AbortSignal;
}) {
signal?.throwIfAborted();
const investigation = await this.investigations.findInvestigation(investigationId);
if (!investigation) return null;
if (investigation.resultId) {
return await this.results.getResult(investigation.resultId, investigation);
}
if (
baseline.workflowId !== investigation.workflowId ||
baseline.projectId !== investigation.projectId ||
baseline.backgroundUserId !== investigation.backgroundUserId ||
baseline.expectedBaseline.publishedVersionId !== investigation.publishedVersionId
) {
throw new UnexpectedError('The baseline does not belong to the investigation.');
}
const {
content,
completedAt,
suggestion: prepared,
} = await this.prepareResult(investigation, input);
} = await this.prepareResult(investigation, baseline, input);
signal?.throwIfAborted();
return await this.txRunner.run({}, async (ctx) => {
@@ -148,7 +164,7 @@ export class SelfHealingResultService {
}
await this.suggestions.readWorkflowTargetForApply(investigation.workflowId, ctx);
await this.suggestions.requireCurrentBaseline(current.baseline, ctx);
await this.suggestions.requireCurrentBaseline(baseline, ctx);
signal?.throwIfAborted();
let suggestionId: string | null = null;
@@ -180,13 +196,13 @@ export class SelfHealingResultService {
private async prepareResult(
investigation: SelfHealingInvestigation,
baseline: WorkflowSuggestionBaseline,
input: SelfHealingResultInput,
) {
if (investigation.finishedAt) {
throw new ConflictError('The self-healing investigation has already finished.');
}
const { baseline } = investigation;
const { output, candidate } = input;
const content = selfHealingResultContentSchema.parse({
@@ -368,7 +384,7 @@ export class SelfHealingResultService {
await this.requireCurrentProject(workflowId, projectId, ctx);
}
if (result.outcome !== 'fix_ready') {
if (result.outcome === 'needs_you') {
await this.results.dismissResult(result.id, user.id, ctx);
}
return true;
@@ -685,7 +685,7 @@ describe('ExecutionLevelTracer', () => {
expect(spans[0].name).toBe('workflow.execute');
});
it('should add custom attributes from tracing metadata', () => {
it('should add prefixed custom attributes and unprefixed GenAI attributes', () => {
tracer.startWorkflow({
executionId: 'exec-7',
tracingContext: inboundTracingContext,
@@ -702,6 +702,7 @@ describe('ExecutionLevelTracer', () => {
inputItemCount: 1,
outputItemCount: 1,
customAttributes: { 'llm.model': 'gpt-4o', 'llm.tokens': '500' },
genAiAttributes: { 'gen_ai.operation.name': 'chat', 'gen_ai.usage.input_tokens': 5 },
});
tracer.endWorkflow({
executionId: 'exec-7',
@@ -713,6 +714,8 @@ describe('ExecutionLevelTracer', () => {
const nodeSpan = otel.getFinishedSpans().find((s) => s.name === 'node.execute')!;
expect(nodeSpan.attributes['n8n.node.custom.llm.model']).toBe('gpt-4o');
expect(nodeSpan.attributes['n8n.node.custom.llm.tokens']).toBe('500');
expect(nodeSpan.attributes['gen_ai.operation.name']).toBe('chat');
expect(nodeSpan.attributes['gen_ai.usage.input_tokens']).toBe(5);
});
it('should not apply workflow custom attributes to node spans', () => {
@@ -1104,11 +1104,20 @@ describe('OtelLifecycleHandler', () => {
expect(tracer.startNode).not.toHaveBeenCalled();
});
it('should call tracer.endNode with counted items and coerced customAttributes', () => {
const chatModelTracing = {
'gen_ai.operation.name': 'chat',
'gen_ai.provider.name': 'openai',
'gen_ai.usage.input_tokens': 5,
'gen_ai.usage.output_tokens': 10,
};
it('should call tracer.endNode with counted items, coerced customAttributes and raw GenAI attributes', () => {
const ctx = makeEndCtx({
source: [{ previousNode: 'Trigger', previousNodeRun: 0 }],
data: { main: [[{ json: {} }, { json: {} }]] },
metadata: { tracing: { 'llm.model': 'gpt-4o', 'llm.tokens': 500 } },
metadata: {
tracing: { 'llm.model': 'gpt-4o', 'llm.tokens': 500, ...chatModelTracing },
},
} as unknown as Partial<NodeExecuteAfterContext['taskData']>);
(ctx.executionData as unknown as IRunExecutionData).resultData.runData = {
Trigger: [
@@ -1131,6 +1140,7 @@ describe('OtelLifecycleHandler', () => {
outputItemCount: 2,
error: undefined,
customAttributes: { 'llm.model': 'gpt-4o', 'llm.tokens': '500' },
genAiAttributes: chatModelTracing,
});
});
@@ -1142,20 +1152,155 @@ describe('OtelLifecycleHandler', () => {
);
});
it('should omit node customAttributes when custom OTel span attributes are not licensed', () => {
it('should omit node customAttributes but keep GenAI attributes when custom OTel span attributes are not licensed', () => {
licenseState.isOtelCustomSpanAttributesLicensed.mockReturnValue(false);
handler.onNodeEnd(
makeEndCtx({
metadata: { tracing: { 'llm.model': 'gpt-4o', 'llm.tokens': 500 } },
metadata: {
tracing: { 'llm.model': 'gpt-4o', 'llm.tokens': 500, ...chatModelTracing },
},
} as unknown as Partial<NodeExecuteAfterContext['taskData']>),
);
expect(tracer.endNode).toHaveBeenCalledWith(
expect.objectContaining({ customAttributes: undefined }),
expect.objectContaining({
customAttributes: undefined,
genAiAttributes: chatModelTracing,
}),
);
});
describe('AI Agent span', () => {
const agentNode = {
id: 'agent-id',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
};
const task = (
executionIndex: number,
source: ({ previousNode: string; previousNodeRun: number } | null)[],
tracing?: Record<string, string | number | boolean>,
) => ({
startTime: 0,
executionTime: 1,
executionIndex,
source,
data: {},
metadata: tracing ? { tracing } : undefined,
});
const chatCall = (
executionIndex: number,
tracing: Record<string, string | number | boolean>,
previousNode = 'AI Agent',
previousNodeRun = 0,
) =>
task(executionIndex, [{ previousNode, previousNodeRun }], {
'gen_ai.operation.name': 'chat',
...tracing,
});
const languageModelOf = (agentName: string) => ({
ai_languageModel: [[{ node: agentName, type: 'ai_languageModel', index: 0 }]],
});
const endAgent = (runData: Record<string, unknown[]>) => {
const agentTask = task(10, []);
handler.onNodeEnd({
type: 'nodeExecuteAfter',
workflow: {
id: 'wf-1',
name: 'Test',
nodes: [agentNode],
connections: {
'OpenAI Chat Model': languageModelOf('AI Agent'),
'Other Chat Model': languageModelOf('Other Agent'),
},
},
nodeName: 'AI Agent',
executionId: 'exec-1',
taskData: agentTask,
executionData: {
resultData: { runData: { 'AI Agent': [agentTask], ...runData }, pinData: {} },
},
} as unknown as NodeExecuteAfterContext);
return tracer.endNode.mock.calls[0][0].genAiAttributes;
};
it('should sum the usage of the chat calls of this Agent run', () => {
const genAiAttributes = endAgent({
'OpenAI Chat Model': [
chatCall(2, {
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o-mini',
'gen_ai.usage.input_tokens': 7,
'gen_ai.usage.output_tokens': 3,
}),
chatCall(1, {
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.usage.input_tokens': 5,
'gen_ai.usage.output_tokens': 10,
}),
chatCall(
3,
{ 'gen_ai.usage.input_tokens': 100, 'gen_ai.usage.output_tokens': 100 },
'AI Agent',
1,
),
],
'Other Chat Model': [
chatCall(
4,
{ 'gen_ai.usage.input_tokens': 100, 'gen_ai.usage.output_tokens': 100 },
'Other Agent',
),
],
});
expect(genAiAttributes).toEqual({
'gen_ai.operation.name': 'invoke_agent',
'gen_ai.agent.name': 'AI Agent',
'gen_ai.agent.id': 'agent-id',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.usage.input_tokens': 12,
'gen_ai.usage.output_tokens': 13,
});
});
it('should mark usage as not reported when a chat call has no usage', () => {
const genAiAttributes = endAgent({
'OpenAI Chat Model': [
chatCall(1, { 'gen_ai.usage.input_tokens': 5, 'gen_ai.usage.output_tokens': 10 }),
chatCall(2, { 'n8n.llm.usage_reported': false }),
],
});
expect(genAiAttributes).toMatchObject({
'gen_ai.usage.input_tokens': 5,
'gen_ai.usage.output_tokens': 10,
'n8n.llm.usage_reported': false,
});
});
it('should set only the agent attributes when the Agent made no chat calls', () => {
const genAiAttributes = endAgent({
Trigger: [task(0, [])],
Tool: [task(1, [null])],
});
expect(genAiAttributes).toEqual({
'gen_ai.operation.name': 'invoke_agent',
'gen_ai.agent.name': 'AI Agent',
'gen_ai.agent.id': 'agent-id',
});
});
});
it('should forward taskData.error to tracer.endNode', () => {
const error = new Error('node failure');
handler.onNodeEnd(
@@ -263,6 +263,7 @@ export class ExecutionLevelTracer {
[ATTR.NODE_ITEMS_INPUT]: params.inputItemCount,
[ATTR.NODE_ITEMS_OUTPUT]: params.outputItemCount,
...this.buildCustomAttributes('node', params.customAttributes),
...params.genAiAttributes,
});
if (params.error) {
@@ -1,3 +1,4 @@
import type { GenAiAttributes } from '@n8n/ai-utilities/gen-ai';
import type { ExecutionStatus, WorkflowExecuteMode, INode } from 'n8n-workflow';
import type { CrashDetector } from '@/events/maps/relay.event-map';
@@ -92,4 +93,5 @@ export type EndNodeParams = {
outputItemCount: number;
error?: EndNodeError;
customAttributes?: Record<string, string>;
genAiAttributes?: GenAiAttributes;
};
@@ -0,0 +1,96 @@
import {
GEN_AI_ATTR,
N8N_USAGE_REPORTED_ATTR,
isGenAiAttributeKey,
type GenAiAttributes,
} from '@n8n/ai-utilities/gen-ai';
import pick from 'lodash/pick';
import pickBy from 'lodash/pickBy';
import {
AGENT_LANGCHAIN_NODE_TYPE,
NodeConnectionTypes,
getParentNodes,
mapConnectionsByDestination,
} from 'n8n-workflow';
import type { IConnections, INode, IRunData, ITaskMetadata } from 'n8n-workflow';
export function buildNodeGenAiAttributes(
node: INode,
tracing: ITaskMetadata['tracing'],
runData: IRunData,
connections: IConnections,
): GenAiAttributes | undefined {
if (node.type === AGENT_LANGCHAIN_NODE_TYPE) {
return {
[GEN_AI_ATTR.OPERATION_NAME]: 'invoke_agent',
[GEN_AI_ATTR.AGENT_NAME]: node.name,
[GEN_AI_ATTR.AGENT_ID]: node.id,
...summarizeChatCalls(node.name, runData, connections),
};
}
const attributes = pickGenAiAttributes(tracing);
return attributes[GEN_AI_ATTR.OPERATION_NAME] ? attributes : undefined;
}
function pickGenAiAttributes(tracing: ITaskMetadata['tracing']): GenAiAttributes {
return pickBy(tracing, (_, key) => isGenAiAttributeKey(key));
}
function summarizeChatCalls(
agentName: string,
runData: IRunData,
connections: IConnections,
): GenAiAttributes {
const agentRunIndex = (runData[agentName]?.length ?? 1) - 1;
const chatModelNames = getParentNodes(
mapConnectionsByDestination(connections),
agentName,
NodeConnectionTypes.AiLanguageModel,
1,
);
let firstCall: { executionIndex: number; attributes: GenAiAttributes } | undefined;
let inputTokens = 0;
let outputTokens = 0;
let hasUsage = false;
let isUsageMissing = false;
for (const task of chatModelNames.flatMap((name) => runData[name] ?? [])) {
const source = task?.source?.[0];
if (source?.previousNode !== agentName) continue;
if ((source.previousNodeRun ?? 0) !== agentRunIndex) continue;
const attributes = pickGenAiAttributes(task.metadata?.tracing);
if (attributes[GEN_AI_ATTR.OPERATION_NAME] !== 'chat') continue;
if (!firstCall || task.executionIndex < firstCall.executionIndex) {
firstCall = { executionIndex: task.executionIndex, attributes };
}
const callInputTokens = attributes[GEN_AI_ATTR.USAGE_INPUT_TOKENS];
const callOutputTokens = attributes[GEN_AI_ATTR.USAGE_OUTPUT_TOKENS];
if (typeof callInputTokens === 'number' && typeof callOutputTokens === 'number') {
inputTokens += callInputTokens;
outputTokens += callOutputTokens;
hasUsage = true;
} else {
isUsageMissing = true;
}
}
if (!firstCall) return {};
return {
...pick(firstCall.attributes, [
GEN_AI_ATTR.PROVIDER_NAME,
GEN_AI_ATTR.REQUEST_MODEL,
GEN_AI_ATTR.RESPONSE_MODEL,
]),
...(hasUsage && {
[GEN_AI_ATTR.USAGE_INPUT_TOKENS]: inputTokens,
[GEN_AI_ATTR.USAGE_OUTPUT_TOKENS]: outputTokens,
}),
...(isUsageMissing && { [N8N_USAGE_REPORTED_ATTR]: false }),
};
}
@@ -1,3 +1,4 @@
import { isGenAiAttributeKey } from '@n8n/ai-utilities/gen-ai';
import { LicenseState, Logger } from '@n8n/backend-common';
import { EventService } from '@n8n/backend-services';
import { WorkflowRepository, type Project } from '@n8n/db';
@@ -16,6 +17,7 @@ import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { toExecutionIdentity } from './execution-identity';
import { ExecutionLevelTracer } from './execution-level-tracer';
import { buildNodeGenAiAttributes } from './gen-ai-attributes';
import type { CustomAttributes, ProjectContext } from './execution-level-tracer.types';
import { OtelSettingsService } from './otel-settings.service';
import { OtelService } from './otel.service';
@@ -234,6 +236,12 @@ export class OtelLifecycleHandler {
outputItemCount: countOutputItems(ctx.taskData.data),
error: ctx.taskData.error ?? undefined,
customAttributes: this.buildNodeCustomAttributes(ctx),
genAiAttributes: buildNodeGenAiAttributes(
node,
ctx.taskData.metadata?.tracing,
ctx.executionData.resultData.runData,
ctx.workflow.connections,
),
});
}
@@ -311,7 +319,9 @@ export class OtelLifecycleHandler {
if (!this.areCustomSpanAttributesLicensed()) return undefined;
return Object.fromEntries(
Object.entries(ctx.taskData.metadata.tracing).map(([key, value]) => [key, String(value)]),
Object.entries(ctx.taskData.metadata.tracing)
.filter(([key]) => !isGenAiAttributeKey(key))
.map(([key, value]) => [key, String(value)]),
);
}
}
@@ -0,0 +1,119 @@
import type { Logger, ModuleRegistry } from '@n8n/backend-common';
import type {
CredentialsRepository,
Role,
Scope as DbScope,
User,
VariablesRepository,
WorkflowRepository,
} from '@n8n/db';
import { ForbiddenError } from '@n8n/errors';
import type { Scope } from '@n8n/permissions';
import { mock } from 'vitest-mock-extended';
import type { DataTableService } from '@/modules/data-table/data-table.service';
import type { DataTableSerializer } from '@/modules/n8n-packages/entities/data-table/data-table.serializer';
import type { TagImporter } from '@/modules/n8n-packages/entities/tag/tag-importer';
import type { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
import { PromotionChangeService } from '../promotion-change.service';
import type { PromotionEnvironmentResolver } from '../promotion-environment.resolver';
import type { PromotionReviewGateService } from '../promotion-review-gate.service';
import type { PromotionsService } from '../promotions.service';
import type { PromotionOperationInput } from '../promotions.types';
const userWith = (...scopes: Scope[]) =>
mock<User>({
role: mock<Role>({ scopes: scopes.map((slug) => mock<DbScope>({ slug })) }),
});
const pusher = userWith('gitConnection:push');
const puller = userWith('gitConnection:pull');
const nobody = userWith();
const branched = (createBranchOnPromotion: boolean) =>
mock<PromotionOperationInput>({
environmentId: 'env-1',
config: {
direction: 'promote',
settings: { schemaVersion: 1, baseBranchName: 'staging', createBranchOnPromotion },
},
});
describe('PromotionChangeService.getPreview review gate', () => {
const promotionsService = mock<PromotionsService>();
const environmentResolver = mock<PromotionEnvironmentResolver>();
const reviewGate = mock<PromotionReviewGateService>();
const logger = mock<Logger>();
logger.scoped.mockReturnValue(logger);
const service = new PromotionChangeService(
promotionsService,
mock<N8nPackagesService>(),
mock<WorkflowRepository>(),
mock<VariablesRepository>(),
mock<CredentialsRepository>(),
mock<TagImporter>(),
mock<DataTableService>(),
mock<DataTableSerializer>(),
mock<ModuleRegistry>(),
environmentResolver,
reviewGate,
logger,
);
const query = { sort: 'name', order: 'asc' } as const;
beforeEach(() => {
// The Git work starts with this read, so a test that expects no Git work asserts it is not called.
promotionsService.readBranchPackage.mockReset();
promotionsService.readBranchPackage.mockRejectedValue(new Error('compare ran'));
environmentResolver.resolveForProject.mockReset();
environmentResolver.resolveForProject.mockResolvedValue(branched(true));
reviewGate.findPromoteBlocker.mockReset();
});
it('returns an empty preview with the blocker and skips the git work', async () => {
reviewGate.findPromoteBlocker.mockResolvedValue({ code: 'reviewOpen', reviewId: 'review-1' });
const preview = await service.getPreview(pusher, 'project-1', 'promote', query);
expect(preview).toEqual({
commitSha: null,
source: { environmentId: 'env-1', branchName: 'staging' },
changes: [],
resources: [],
dependencies: [],
blocker: { code: 'reviewOpen', reviewId: 'review-1' },
});
expect(reviewGate.findPromoteBlocker).toHaveBeenCalledWith('project-1', 'env-1');
expect(promotionsService.readBranchPackage).not.toHaveBeenCalled();
});
it('does not run the gate when the promote pushes directly', async () => {
environmentResolver.resolveForProject.mockResolvedValue(branched(false));
await expect(service.getPreview(pusher, 'project-1', 'promote', query)).rejects.toThrow(
'compare ran',
);
expect(reviewGate.findPromoteBlocker).not.toHaveBeenCalled();
});
it('does not run the gate for an apply preview', async () => {
await expect(service.getPreview(puller, 'project-1', 'apply', query)).rejects.toThrow(
'compare ran',
);
expect(environmentResolver.resolveForProject).not.toHaveBeenCalled();
expect(reviewGate.findPromoteBlocker).not.toHaveBeenCalled();
});
it('checks the permission before the gate', async () => {
await expect(service.getPreview(nobody, 'project-1', 'promote', query)).rejects.toThrow(
ForbiddenError,
);
expect(environmentResolver.resolveForProject).not.toHaveBeenCalled();
expect(reviewGate.findPromoteBlocker).not.toHaveBeenCalled();
expect(promotionsService.readBranchPackage).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,169 @@
import type { PromotionReviewState } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import { mock } from 'vitest-mock-extended';
import type { PromotionReview } from '../database/entities/promotion-review.entity';
import type { PromotionRun } from '../database/entities/promotion-run.entity';
import type { PromotionReviewRepository } from '../database/repositories/promotion-review.repository';
import type { PromotionRunRepository } from '../database/repositories/promotion-run.repository';
import {
PROMOTE_GATE_READ_TIMEOUT_MS,
PromotionReviewGateService,
reviewCoversProject,
} from '../promotion-review-gate.service';
import type {
LiveReviewState,
PromotionReviewInboxService,
} from '../promotion-review-inbox.service';
// A plain object: a mock would answer a missing optional field with a function.
const run = (reviewId: string, projectIds?: string[], details: unknown = undefined) =>
({
reviewId,
type: 'git',
details: details ?? {
schemaVersion: 1,
branchName: `branch-${reviewId}`,
commitSha: 'a'.repeat(40),
...(projectIds && { contents: { projectIds } }),
},
}) as PromotionRun;
const live = (state: PromotionReviewState): LiveReviewState => ({ ok: true, state });
describe('reviewCoversProject', () => {
it('covers every project when a run lists none', () => {
expect(reviewCoversProject([run('r1')], 'p1')).toBe(true);
expect(reviewCoversProject([], 'p1')).toBe(true);
});
it('covers a project that a run lists, and no other', () => {
expect(reviewCoversProject([run('r1', ['p1'])], 'p1')).toBe(true);
expect(reviewCoversProject([run('r1', ['p1'])], 'p2')).toBe(false);
});
it('joins the projects of several runs, and one legacy run covers all', () => {
expect(reviewCoversProject([run('r1', ['p1']), run('r1', ['p2'])], 'p2')).toBe(true);
expect(reviewCoversProject([run('r1', ['p1']), run('r1')], 'p9')).toBe(true);
});
});
describe('PromotionReviewGateService', () => {
const inbox = mock<PromotionReviewInboxService>();
const reviewRepository = mock<PromotionReviewRepository>();
const runRepository = mock<PromotionRunRepository>();
const logger = mock<Logger>();
logger.scoped.mockReturnValue(logger);
const service = new PromotionReviewGateService(inbox, reviewRepository, runRepository, logger);
const review = (id: string) => mock<PromotionReview>({ id });
beforeEach(() => {
vi.resetAllMocks();
logger.scoped.mockReturnValue(logger);
inbox.isEnabled.mockReturnValue(true);
});
it('does not read the host when the environment has no open review', async () => {
reviewRepository.findOpenByEnvironmentId.mockResolvedValue([]);
await expect(service.findPromoteBlocker('p1', 'env-1')).resolves.toBeNull();
expect(inbox.refreshState).not.toHaveBeenCalled();
expect(runRepository.findByReviewIds).not.toHaveBeenCalled();
});
it('does nothing when promotion reviews are off', async () => {
inbox.isEnabled.mockReturnValue(false);
await expect(service.findPromoteBlocker('p1', 'env-1')).resolves.toBeNull();
expect(reviewRepository.findOpenByEnvironmentId).not.toHaveBeenCalled();
});
it.each<PromotionReviewState>(['open', 'changes_requested'])(
'blocks a covered project while the host says %s',
async (state) => {
reviewRepository.findOpenByEnvironmentId.mockResolvedValue([review('r1')]);
runRepository.findByReviewIds.mockResolvedValue([run('r1', ['p1'])]);
inbox.refreshState.mockResolvedValue(live(state));
await expect(service.findPromoteBlocker('p1', 'env-1')).resolves.toEqual({
code: 'reviewOpen',
reviewId: 'r1',
});
expect(inbox.refreshState).toHaveBeenCalledWith(
expect.objectContaining({ id: 'r1' }),
[expect.objectContaining({ reviewId: 'r1' })],
{ timeoutMs: PROMOTE_GATE_READ_TIMEOUT_MS },
);
},
);
it.each<PromotionReviewState>(['merged', 'closed'])(
'lets the promote through when the host says %s',
async (state) => {
reviewRepository.findOpenByEnvironmentId.mockResolvedValue([review('r1')]);
runRepository.findByReviewIds.mockResolvedValue([run('r1', ['p1'])]);
inbox.refreshState.mockResolvedValue(live(state));
await expect(service.findPromoteBlocker('p1', 'env-1')).resolves.toBeNull();
},
);
it('does not read a review that covers another project', async () => {
reviewRepository.findOpenByEnvironmentId.mockResolvedValue([review('r1')]);
runRepository.findByReviewIds.mockResolvedValue([run('r1', ['p2'])]);
await expect(service.findPromoteBlocker('p1', 'env-1')).resolves.toBeNull();
expect(inbox.refreshState).not.toHaveBeenCalled();
});
it.each(['unreachable'] as const)(
'lets the promote through and logs when the host read fails with %s',
async (remoteError) => {
reviewRepository.findOpenByEnvironmentId.mockResolvedValue([review('r1')]);
runRepository.findByReviewIds.mockResolvedValue([run('r1', ['p1'])]);
inbox.refreshState.mockResolvedValue({ ok: false, remoteError });
await expect(service.findPromoteBlocker('p1', 'env-1')).resolves.toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ reviewId: 'r1', remoteError }),
);
},
);
it('keeps a blocker from one review when the read of another fails', async () => {
reviewRepository.findOpenByEnvironmentId.mockResolvedValue([review('r1'), review('r2')]);
runRepository.findByReviewIds.mockResolvedValue([run('r1', ['p1']), run('r2', ['p1'])]);
inbox.refreshState.mockImplementation(async ({ id }) =>
id === 'r1' ? { ok: false, remoteError: 'unreachable' } : live('open'),
);
await expect(service.findPromoteBlocker('p1', 'env-1')).resolves.toEqual({
code: 'reviewOpen',
reviewId: 'r2',
});
});
it.each(['review lookup', 'run lookup'])(
'lets the promote through when the %s fails',
async (failing) => {
if (failing === 'review lookup') {
reviewRepository.findOpenByEnvironmentId.mockRejectedValue(new Error('db down'));
} else {
reviewRepository.findOpenByEnvironmentId.mockResolvedValue([review('r1')]);
runRepository.findByReviewIds.mockRejectedValue(new Error('db down'));
}
await expect(service.findPromoteBlocker('p1', 'env-1')).resolves.toBeNull();
expect(logger.warn).toHaveBeenCalled();
expect(inbox.refreshState).not.toHaveBeenCalled();
},
);
});
@@ -0,0 +1,163 @@
import type { LicenseState, Logger } from '@n8n/backend-common';
import { ServiceUnavailableError } from '@n8n/errors';
import { mock } from 'vitest-mock-extended';
import type { PromotionEnvironment } from '../database/entities/promotion-environment.entity';
import type { PromotionReview } from '../database/entities/promotion-review.entity';
import type { PromotionRun } from '../database/entities/promotion-run.entity';
import type { PromotionReviewRepository } from '../database/repositories/promotion-review.repository';
import type { PromotionRunRepository } from '../database/repositories/promotion-run.repository';
import type { GitLabMergeRequestClient } from '../git-hosts/gitlab-merge-request.client';
import type { GitLabMergeRequest } from '../git-hosts/gitlab-merge-request.types';
import type { PromotionReviewHostResolver } from '../promotion-review-host.resolver';
import { PromotionReviewInboxService } from '../promotion-review-inbox.service';
const BRANCH = 'n8n-promotion/2026-10-05T10-00-00-000Z';
describe('PromotionReviewInboxService.refreshState', () => {
const reviewRepository = mock<PromotionReviewRepository>();
const runRepository = mock<PromotionRunRepository>();
const hostResolver = mock<PromotionReviewHostResolver>();
const mergeRequests = mock<GitLabMergeRequestClient>();
const logger = mock<Logger>();
logger.scoped.mockReturnValue(logger);
const service = new PromotionReviewInboxService(
mock<LicenseState>(),
reviewRepository,
runRepository,
hostResolver,
mergeRequests,
logger,
);
const review = (overrides: Partial<PromotionReview> = {}) =>
mock<PromotionReview>({
id: 'review-1',
type: 'gitlab',
state: 'open',
remoteReviewId: '7!3',
environment: mock<PromotionEnvironment>({ id: 'env-1' }),
...overrides,
});
const mergeRequest = (overrides: Partial<GitLabMergeRequest> = {}) =>
({
iid: 3,
project_id: 7,
state: 'opened',
source_branch: BRANCH,
detailed_merge_status: 'mergeable',
...overrides,
}) as GitLabMergeRequest;
// A plain object: a mock would answer a missing optional field with a function.
const runOf = (reviewId: string) =>
({
reviewId,
type: 'git',
details: { schemaVersion: 1, branchName: BRANCH, commitSha: 'a'.repeat(40) },
createdAt: new Date('2026-10-05T10:00:00Z'),
}) as PromotionRun;
const runs = () => [runOf('review-1')];
beforeEach(() => {
vi.resetAllMocks();
hostResolver.resolveForEnvironment.mockResolvedValue({
baseUrl: 'https://gitlab.example.com',
accessToken: 'token',
projectPath: 'platform/api',
} as never);
});
it('reads the merge request with the given timeout', async () => {
mergeRequests.getMergeRequest.mockResolvedValue(mergeRequest());
await expect(service.refreshState(review(), runs(), { timeoutMs: 3_000 })).resolves.toEqual({
ok: true,
state: 'open',
});
expect(mergeRequests.getMergeRequest).toHaveBeenCalledWith(
expect.anything(),
{ projectId: 7, iid: 3 },
{ timeoutMs: 3_000 },
);
// The caller passes the runs it already loaded.
expect(runRepository.findByReviewIds).not.toHaveBeenCalled();
});
it.each(['merged', 'closed'] as const)(
'records a %s review the way the Inbox sync does',
async (state) => {
const stored = review();
reviewRepository.recordHostState.mockResolvedValue(true);
mergeRequests.getMergeRequest.mockResolvedValue(mergeRequest({ state }));
await service.refreshState(stored, runs(), { timeoutMs: 3_000 });
expect(reviewRepository.recordHostState).toHaveBeenCalledWith(
'review-1',
state,
expect.any(Date),
);
expect(stored.state).toBe(state);
},
);
it('records nothing when the read fails', async () => {
mergeRequests.getMergeRequest.mockRejectedValue(new ServiceUnavailableError('slow'));
await service.refreshState(review(), runs(), { timeoutMs: 3_000 });
expect(reviewRepository.recordHostState).not.toHaveBeenCalled();
});
it('still returns the host state when the write fails', async () => {
reviewRepository.recordHostState.mockRejectedValue(new Error('db down'));
mergeRequests.getMergeRequest.mockResolvedValue(mergeRequest());
reviewRepository.recordHostState.mockClear();
await expect(service.refreshState(review(), runs(), { timeoutMs: 3_000 })).resolves.toEqual({
ok: true,
state: 'open',
});
mergeRequests.getMergeRequest.mockResolvedValue(mergeRequest({ state: 'merged' }));
await expect(service.refreshState(review(), runs(), { timeoutMs: 3_000 })).resolves.toEqual({
ok: true,
state: 'merged',
});
});
it('rejects a merge request on a branch that n8n did not push', async () => {
mergeRequests.getMergeRequest.mockResolvedValue(mergeRequest({ source_branch: 'other' }));
await expect(service.refreshState(review(), runs(), { timeoutMs: 3_000 })).resolves.toEqual({
ok: false,
remoteError: 'not_found',
});
});
it('shares one host call between callers that ask at the same time', async () => {
let release: (value: GitLabMergeRequest) => void = () => {};
mergeRequests.getMergeRequest.mockReturnValueOnce(
new Promise<GitLabMergeRequest>((resolve) => {
release = resolve;
}),
);
const first = service.refreshState(review(), runs(), { timeoutMs: 3_000 });
const second = service.refreshState(review(), runs(), { timeoutMs: 3_000 });
await vi.waitFor(() => expect(mergeRequests.getMergeRequest).toHaveBeenCalledTimes(1));
release(mergeRequest());
await expect(first).resolves.toEqual({ ok: true, state: 'open' });
await expect(second).resolves.toEqual({ ok: true, state: 'open' });
expect(mergeRequests.getMergeRequest).toHaveBeenCalledTimes(1);
mergeRequests.getMergeRequest.mockResolvedValueOnce(mergeRequest());
await service.refreshState(review(), runs(), { timeoutMs: 3_000 });
expect(mergeRequests.getMergeRequest).toHaveBeenCalledTimes(2);
});
});
@@ -86,6 +86,7 @@ import { PromotionBindingPreflightService } from '../promotion-binding-preflight
import { PromotionChangeService } from '../promotion-change.service';
import { PromotionEnvironmentResolver } from '../promotion-environment.resolver';
import { PromotionProvidersService } from '../promotion-providers.service';
import { PromotionReviewGateService } from '../promotion-review-gate.service';
import { PromotionReviewsService } from '../promotion-reviews.service';
import { PromotionWorkingDirectoryService } from '../promotion-working-directory.service';
import { PromotionsGitService } from '../promotions-git.service';
@@ -479,6 +480,8 @@ async function listApplyChanges(projectId: string) {
Container.get(DataTableService),
Container.get(DataTableSerializer),
Container.get(ModuleRegistry),
Container.get(PromotionEnvironmentResolver),
Container.get(PromotionReviewGateService),
mockLogger(),
);
const { changes } = await changeService.getChanges(owner, projectId, 'apply', {
@@ -459,6 +459,13 @@ describe('PromotionsService', () => {
webUrl: 'https://gitlab/mr/3',
});
expect(result.warnings).toBeUndefined();
expect(runRepository.insertRun).toHaveBeenCalledWith(
expect.objectContaining({
details: expect.objectContaining({
contents: { projectIds: ['project-a', 'project-b'] },
}),
}),
);
});
it('does not open a review for a direct push to the base branch', async () => {
@@ -47,6 +47,15 @@ export class PromotionReviewRepository extends BaseRepository<PromotionReview> {
return await this.find({ where: { state: In(OPEN_STATES) }, relations: { environment: true } });
}
/** Reviews of one environment that still wait on the host. The promote gate reads these. */
async findOpenByEnvironmentId(environmentId: string): Promise<PromotionReview[]> {
return await this.find({
where: { environmentId, state: In(OPEN_STATES) },
relations: { environment: true },
order: { createdAt: 'DESC' },
});
}
/** Writes a host state only while the row is open, so a terminal row never changes. */
async recordHostState(id: string, state: PromotionReviewState, at: Date): Promise<boolean> {
const result = await this.update(
@@ -103,6 +103,17 @@ describe('GitLabMergeRequestClient', () => {
});
});
it('reads a merge request with the default timeout, or with a shorter one', async () => {
respond(200, mergeRequest);
respond(200, mergeRequest);
await client.getMergeRequest(access, { projectId: 7, iid: 3 });
expect(lastRequest()).toMatchObject({ method: 'GET', timeout: 15_000 });
await client.getMergeRequest(access, { projectId: 7, iid: 3 }, { timeoutMs: 3_000 });
expect(lastRequest()).toMatchObject({ timeout: 3_000 });
});
it('shortens a title to the GitLab limit', async () => {
respond(201, mergeRequest);
@@ -67,6 +67,7 @@ type ApiRequest = {
path: string;
qs?: IDataObject;
body?: IDataObject;
timeoutMs?: number;
};
/** Stores one merge request as `<projectId>!<iid>`. The id stays valid after a rename. */
@@ -123,10 +124,12 @@ export class GitLabMergeRequestClient {
async getMergeRequest(
access: GitHostAccess,
{ projectId, iid }: MergeRequestRef,
{ timeoutMs }: { timeoutMs?: number } = {},
): Promise<GitLabMergeRequest> {
const { body } = await this.request(access, {
method: 'GET',
path: `projects/${projectId}/merge_requests/${iid}`,
timeoutMs,
});
return this.parse(body);
}
@@ -173,7 +176,7 @@ export class GitLabMergeRequestClient {
private async request(
access: GitHostAccess,
{ method, path, qs, body }: ApiRequest,
{ method, path, qs, body, timeoutMs }: ApiRequest,
): Promise<TypedHttpFullResponse<unknown>> {
let response: TypedHttpFullResponse<unknown>;
try {
@@ -187,7 +190,7 @@ export class GitLabMergeRequestClient {
body,
headers: { 'PRIVATE-TOKEN': access.accessToken },
json: true,
timeout: requestTimeoutMs,
timeout: timeoutMs ?? requestTimeoutMs,
maxResponseBodyBytes,
returnFullResponse: true,
ignoreHttpStatusErrors: true,
@@ -74,7 +74,10 @@ import {
type ApplyDestination,
type PreviewSide,
} from './promotion-preview';
import { PromotionEnvironmentResolver } from './promotion-environment.resolver';
import { PromotionReviewGateService } from './promotion-review-gate.service';
import { PromotionsService } from './promotions.service';
import { checkoutBranchName } from './promotions-git.utils';
import type { BranchPackage } from './promotions.types';
const DEPENDENCY_COLLECTIONS = {
@@ -143,6 +146,8 @@ export class PromotionChangeService {
private readonly dataTableService: DataTableService,
private readonly dataTableSerializer: DataTableSerializer,
private readonly moduleRegistry: ModuleRegistry,
private readonly environmentResolver: PromotionEnvironmentResolver,
private readonly reviewGate: PromotionReviewGateService,
private readonly logger: Logger,
) {
this.logger = this.logger.scoped('promotions');
@@ -173,6 +178,30 @@ export class PromotionChangeService {
direction: PromotionDirection,
query: PromotionChangesQueryDto,
): Promise<PromotionPreview> {
if (direction === 'promote') {
// The gate runs before the git and export work, so a blocked project skips both.
this.assertCanPreview(user, direction);
const input = await this.environmentResolver.resolveForProject(projectId, direction);
// Only a branched promote opens a review. A direct push has nothing to wait for, even
// when an older review of the environment is still open.
const blocker =
input.config.direction === 'promote' && input.config.settings.createBranchOnPromotion
? await this.reviewGate.findPromoteBlocker(projectId, input.environmentId)
: null;
if (blocker) {
return {
commitSha: null,
source: {
environmentId: input.environmentId,
branchName: checkoutBranchName(input.config),
},
changes: [],
resources: [],
dependencies: [],
blocker,
};
}
}
const comparison = await this.compareProject(user, projectId, direction);
const { branch, instance, archiveState } = comparison;
const branchSide: PreviewSide = {
@@ -0,0 +1,96 @@
import {
promotionRunDetailsSchemas,
type PromotionBlocker,
type PromotionReviewState,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import type { PromotionReview } from './database/entities/promotion-review.entity';
import type { PromotionRun } from './database/entities/promotion-run.entity';
import { PromotionReviewRepository } from './database/repositories/promotion-review.repository';
import { PromotionRunRepository } from './database/repositories/promotion-run.repository';
import { PromotionReviewInboxService } from './promotion-review-inbox.service';
/** The live read must not stall the preview when GitLab is slow. */
export const PROMOTE_GATE_READ_TIMEOUT_MS = 3_000;
const BLOCKING_STATES: PromotionReviewState[] = ['open', 'changes_requested'];
/**
* Whether the runs of one review cover a project. A run without `contents.projectIds` covers every
* project of its environment, so the live read decides. This is the safe direction.
*/
export const reviewCoversProject = (runs: PromotionRun[], projectId: string) => {
if (runs.length === 0) return true;
const covered = new Set<string>();
for (const run of runs) {
const parsed = promotionRunDetailsSchemas[run.type].safeParse(run.details);
const projectIds = parsed.success ? parsed.data.contents?.projectIds : undefined;
if (!projectIds) return true;
for (const id of projectIds) covered.add(id);
}
return covered.has(projectId);
};
/**
* Finds the open review that already holds the promote changes of a project. The stored state
* can be old, so each candidate gets one live read. Every failure lets the promote through:
* a review that n8n cannot read must never block a project for good.
*/
@Service()
export class PromotionReviewGateService {
constructor(
private readonly inbox: PromotionReviewInboxService,
private readonly reviewRepository: PromotionReviewRepository,
private readonly runRepository: PromotionRunRepository,
private readonly logger: Logger,
) {
this.logger = this.logger.scoped('promotions');
}
async findPromoteBlocker(
projectId: string,
environmentId: string,
): Promise<PromotionBlocker | null> {
if (!this.inbox.isEnabled()) return null;
try {
const open = await this.reviewRepository.findOpenByEnvironmentId(environmentId);
if (open.length === 0) return null;
const runs = await this.runRepository.findByReviewIds(open.map(({ id }) => id));
const runsOf = (review: PromotionReview) => runs.filter((run) => run.reviewId === review.id);
const covering = open.filter((review) => reviewCoversProject(runsOf(review), projectId));
const states = await Promise.all(
covering.map(async (review) => await this.readState(review, runsOf(review))),
);
const blocking = covering.find((_, index) => states[index]);
return blocking ? { code: 'reviewOpen', reviewId: blocking.id } : null;
} catch (error) {
this.logger.warn('Could not check for an open promotion review', { environmentId, error });
return null;
}
}
/** True when the host reports the review as still open. */
private async readState(review: PromotionReview, runs: PromotionRun[]): Promise<boolean> {
try {
const result = await this.inbox.refreshState(review, runs, {
timeoutMs: PROMOTE_GATE_READ_TIMEOUT_MS,
});
if (!result.ok) {
this.logger.warn('Could not read a promotion review from the host', {
reviewId: review.id,
remoteError: result.remoteError,
});
return false;
}
return BLOCKING_STATES.includes(result.state);
} catch (error) {
this.logger.warn('Could not read a promotion review from the host', {
reviewId: review.id,
error,
});
return false;
}
}
}
@@ -38,6 +38,11 @@ import type { GitLabMergeRequest, MergeRequestRef } from './git-hosts/gitlab-mer
import { PromotionReviewHostResolver } from './promotion-review-host.resolver';
import { approvalNote } from './promotion-review.utils';
/** One live look at a review on the host. A failed read says why, and never a state. */
export type LiveReviewState =
| { ok: true; state: PromotionReviewState }
| { ok: false; remoteError: PromotionReviewRemoteError };
type RemoteView = Readonly<{
mergeRequest: GitLabMergeRequest | null;
remoteError: PromotionReviewRemoteError | null;
@@ -64,6 +69,8 @@ const UNCONFIRMED_MERGE =
export class PromotionReviewInboxService {
private readonly syncOnce = singleFlight(async () => await this.syncOpenReviews());
private readonly liveReads = new Map<string, Promise<LiveReviewState>>();
constructor(
private readonly licenseState: LicenseState,
private readonly reviewRepository: PromotionReviewRepository,
@@ -108,6 +115,42 @@ export class PromotionReviewInboxService {
return await this.reviewRepository.countForInbox();
}
/**
* One live look at a review on the host. A merged or closed review is recorded the same way
* as in the Inbox sync, so the next caller finds the new state without a host call. Callers
* that ask for the same review at the same time share one host call. `runs` are the runs of
* this review that the caller already loaded.
*/
async refreshState(
review: PromotionReview,
runs: PromotionRun[],
{ timeoutMs }: { timeoutMs: number },
): Promise<LiveReviewState> {
const inFlight = this.liveReads.get(review.id);
if (inFlight) return await inFlight;
const read = this.readLive(review, runs, timeoutMs).finally(() =>
this.liveReads.delete(review.id),
);
this.liveReads.set(review.id, read);
return await read;
}
private async readLive(
review: PromotionReview,
runs: PromotionRun[],
timeoutMs: number,
): Promise<LiveReviewState> {
const run = newestFirst(runs)[0];
const { mergeRequest, remoteError } = await this.readOne(review, run, new Map(), timeoutMs);
if (!mergeRequest) return { ok: false, remoteError: remoteError ?? 'unreachable' };
try {
await this.applyHostState(review, mergeRequest);
} catch {
// The host answer is right for this call. The Inbox sync records the state later.
}
return { ok: true, state: toReviewState(mergeRequest) };
}
/**
* Approves and merges on the host as the GitLab user of the token, then names the n8n
* approver in a note. The stored state can be old, so this reads the host first.
@@ -227,9 +270,7 @@ export class PromotionReviewInboxService {
private async newestRuns(reviews: PromotionReview[]): Promise<Map<string, PromotionRun>> {
const runs = await this.runRepository.findByReviewIds(reviews.map(({ id }) => id));
const newest = new Map<string, PromotionRun>();
for (const run of runs.toSorted(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id),
)) {
for (const run of newestFirst(runs)) {
if (run.reviewId && !newest.has(run.reviewId)) newest.set(run.reviewId, run);
}
return newest;
@@ -251,9 +292,10 @@ export class PromotionReviewInboxService {
review: PromotionReview,
run: PromotionRun | undefined,
accessCache: AccessCache,
timeoutMs?: number,
): Promise<RemoteView> {
try {
const host = await this.readMergeRequest(review, run, accessCache);
const host = await this.readMergeRequest(review, run, accessCache, timeoutMs);
return host
? { mergeRequest: host.mergeRequest, remoteError: null }
: failed('no_connection');
@@ -267,6 +309,7 @@ export class PromotionReviewInboxService {
{ type, environment, remoteReviewId }: PromotionReview,
run: PromotionRun | undefined,
accessCache: AccessCache = new Map(),
timeoutMs?: number,
) {
if (!environment || !remoteReviewId) return null;
const key = `${environment.id}:${type}`;
@@ -278,7 +321,7 @@ export class PromotionReviewInboxService {
const access = await resolving;
if (!access) return null;
const ref = parseGitLabReviewId(remoteReviewId);
const mergeRequest = await this.mergeRequests.getMergeRequest(access, ref);
const mergeRequest = await this.mergeRequests.getMergeRequest(access, ref, { timeoutMs });
// The stored ids are not unique across hosts. Only the branch n8n pushed proves it is ours.
if (mergeRequest.source_branch !== detailsOf(run)?.branchName) {
throw new NotFoundError("GitLab couldn't find the merge request of this promotion.");
@@ -332,6 +375,11 @@ const toReviewUser = (user: User | null) =>
? { id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email }
: null;
const newestFirst = (runs: PromotionRun[]) =>
runs.toSorted(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id),
);
const detailsOf = (run: PromotionRun | undefined) => {
if (!run) return null;
const parsed = promotionRunDetailsSchemas[run.type].safeParse(run.details);
@@ -13,6 +13,7 @@ import {
type PromotionBindingConflict,
type PromotionCheckoutPublicDto,
type PromotionDirection,
type PromotionRunContents,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { ProjectRepository, SharedWorkflowRepository, type User } from '@n8n/db';
@@ -256,6 +257,7 @@ export class PromotionsService {
promotionBranchName: targetBranchName,
commitSha: pushed.commitSha,
title: request.commitMessage,
promotionRunContents: { projectIds },
})),
};
}
@@ -264,11 +266,18 @@ export class PromotionsService {
private async openReview(
input: PromotionOperationInput,
actor: User,
push: { promotionBranchName: string | undefined; commitSha: string; title: string },
push: {
promotionBranchName: string | undefined;
commitSha: string;
title: string;
promotionRunContents: PromotionRunContents;
},
): Promise<Pick<PromotePackageResultDto, 'review' | 'warnings'>> {
const recorded = await this.recordPromoteRun(input, actor, {
branchName: push.promotionBranchName ?? checkoutBranchName(input.config),
commitSha: push.commitSha,
// Only a promotion branch has a review, and the review gate reads the project list.
...(push.promotionBranchName && { promotionRunContents: push.promotionRunContents }),
});
// A review without a run cannot be rendered: the branch and commit live on the run.
if (!recorded.runId) {
@@ -317,7 +326,7 @@ export class PromotionsService {
private async recordPromoteRun(
input: PromotionOperationInput,
actor: User,
git: { branchName: string; commitSha: string },
git: { branchName: string; commitSha: string; promotionRunContents?: PromotionRunContents },
): Promise<{ runId?: string }> {
try {
const type = promotionRunTypeSchema.parse('git');
@@ -330,6 +339,7 @@ export class PromotionsService {
schemaVersion: 1,
branchName: git.branchName,
commitSha: git.commitSha,
...(git.promotionRunContents && { contents: git.promotionRunContents }),
}),
reviewId: null,
});
@@ -495,6 +505,7 @@ export class PromotionsService {
promotionBranchName: targetBranchName,
commitSha: pushed.commitSha,
title: request.commitMessage,
promotionRunContents: { projectIds: [selection.projectId] },
})),
};
}
@@ -479,6 +479,12 @@ describe('SourceControlExportService', () => {
});
describe('exportTagsToWorkFolder', () => {
beforeEach(() => {
fsReadFile.mockResolvedValue(JSON.stringify({ tags: [], mappings: [] }));
workflowTagMappingRepository.find.mockResolvedValue([]);
workflowRepository.find.mockResolvedValue([]);
});
it('should export tags to work folder', async () => {
// Arrange
const mockTag = mock<TagEntity>({
@@ -549,6 +555,7 @@ describe('SourceControlExportService', () => {
});
const accessibleMapping = { tagId: 'tag1', workflowId: 'workflow1' } as WorkflowTagMapping;
const inaccessibleMapping = { tagId: 'tag2', workflowId: 'workflow2' };
const remoteTag = { id: 'tag2', name: 'Tag 2' };
tagRepository.find.mockResolvedValue([mockTag]);
workflowTagMappingRepository.find.mockResolvedValue([accessibleMapping]);
workflowRepository.find.mockResolvedValue([
@@ -556,7 +563,7 @@ describe('SourceControlExportService', () => {
]);
fsReadFile.mockResolvedValueOnce(
JSON.stringify({
tags: [],
tags: [remoteTag],
mappings: [{ tagId: 'stale', workflowId: 'workflow1' }, inaccessibleMapping],
}),
);
@@ -569,10 +576,56 @@ describe('SourceControlExportService', () => {
);
const dataCaptor = captor<string>();
expect(fsWriteFile).toHaveBeenCalledWith('/mock/n8n/git/tags.json', dataCaptor);
expect(JSON.parse(dataCaptor.value).mappings).toEqual([
inaccessibleMapping,
accessibleMapping,
]);
expect(JSON.parse(dataCaptor.value)).toEqual({
tags: [{ id: mockTag.id, name: mockTag.name }, remoteTag],
mappings: [inaccessibleMapping, accessibleMapping],
});
});
it('should keep a remote tag and its mappings when there are no local tags', async () => {
const remoteTag = { id: 'remote-tag', name: 'Remote tag' };
const remoteMappings = [
{ tagId: remoteTag.id, workflowId: 'workflow1' },
{ tagId: remoteTag.id, workflowId: 'workflow2' },
];
tagRepository.find.mockResolvedValue([]);
fsReadFile.mockResolvedValueOnce(
JSON.stringify({
tags: [remoteTag, { id: 'unused-tag', name: 'Unused tag' }],
mappings: remoteMappings,
}),
);
const result = await service.exportTagsToWorkFolder(globalAdminContext);
const dataCaptor = captor<string>();
expect(fsWriteFile).toHaveBeenCalledWith('/mock/n8n/git/tags.json', dataCaptor);
expect(JSON.parse(dataCaptor.value)).toEqual({
tags: [remoteTag],
mappings: remoteMappings,
});
expect(result.count).toBe(1);
});
it('should skip remote mappings whose tags are missing locally and remotely', async () => {
const localTag = mock<TagEntity>({ id: 'local-tag', name: 'Local tag' });
const validMapping = { tagId: localTag.id, workflowId: 'workflow1' };
tagRepository.find.mockResolvedValue([localTag]);
fsReadFile.mockResolvedValueOnce(
JSON.stringify({
tags: [],
mappings: [validMapping, { tagId: 'missing-tag', workflowId: 'workflow2' }],
}),
);
await service.exportTagsToWorkFolder(globalAdminContext);
const dataCaptor = captor<string>();
expect(fsWriteFile).toHaveBeenCalledWith('/mock/n8n/git/tags.json', dataCaptor);
expect(JSON.parse(dataCaptor.value)).toEqual({
tags: [{ id: localTag.id, name: localTag.name }],
mappings: [validMapping],
});
});
});
@@ -425,16 +425,7 @@ export class SourceControlExportService {
const fileName = path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE);
sourceControlFoldersExistCheck([this.gitFolder]);
const tags = await this.tagRepository.find();
if (tags.length === 0) {
await fsWriteFile(fileName, JSON.stringify({ tags: [], mappings: [] }, null, 2));
return {
count: 0,
folder: this.gitFolder,
files: [{ id: '', name: fileName }],
};
}
const tagsById = new Map(tags.map((tag) => [tag.id, tag]));
const mappingsOfAllowedWorkflows = await this.workflowTagMappingRepository.find({
where:
@@ -451,18 +442,24 @@ export class SourceControlExportService {
const allowedWorkflowIds = new Set(allowedWorkflows.map((workflow) => workflow.id));
const existingTagsAndMapping = await readTagAndMappingsFromSourceControlFile(fileName);
const remoteTagsById = new Map(existingTagsAndMapping.tags.map((tag) => [tag.id, tag]));
// keep all mappings that are not accessible by the current user
const mappingsToKeep = existingTagsAndMapping.mappings.filter(
(mapping) => !allowedWorkflowIds.has(mapping.workflowId),
);
// Keep tags with remote mappings outside the user's scope.
const mappingsToKeep = existingTagsAndMapping.mappings.filter((mapping) => {
if (allowedWorkflowIds.has(mapping.workflowId)) return false;
if (!tagsById.has(mapping.tagId)) {
const remoteTag = remoteTagsById.get(mapping.tagId);
if (!remoteTag) return false;
tagsById.set(remoteTag.id, remoteTag);
}
return true;
});
await fsWriteFile(
fileName,
JSON.stringify(
{
// overwrite all tags
tags: tags.map((tag) => ({ id: tag.id, name: tag.name })),
tags: Array.from(tagsById.values(), (tag) => ({ id: tag.id, name: tag.name })),
mappings: mappingsToKeep.concat(mappingsOfAllowedWorkflows),
},
null,
@@ -470,7 +467,7 @@ export class SourceControlExportService {
),
);
return {
count: tags.length,
count: tagsById.size,
folder: this.gitFolder,
files: [{ id: '', name: fileName }],
};
@@ -1345,9 +1345,20 @@ export class SourceControlImportService {
]),
].filter((workflowId) => existingWorkflowIds.has(workflowId));
const mappingsToImport = mappedTags.mappings.filter((mapping) =>
existingWorkflowIds.has(String(mapping.workflowId)),
const existingTagIds = new Set(
(await this.tagRepository.find({ select: ['id'] })).map((tag) => tag.id),
);
const mappingsToImport = mappedTags.mappings.filter((mapping) => {
if (!existingWorkflowIds.has(String(mapping.workflowId))) return false;
if (!existingTagIds.has(String(mapping.tagId))) {
this.logger.warn('Skipping tag mapping because the tag does not exist locally', {
tagId: mapping.tagId,
workflowId: mapping.workflowId,
});
return false;
}
return true;
});
/**
* Use a transaction here as we "delete" them all and then "create" them again.
+3 -1
View File
@@ -1,3 +1,5 @@
import type { Schema } from 'jsonschema';
type PaginationBase = { limit: number };
export type PaginationOffsetDecoded = PaginationBase & { offset: number };
@@ -19,7 +21,7 @@ export interface IDependency {
export interface IJsonSchema {
additionalProperties: false;
type: 'object';
properties: { [key: string]: { type: string } };
properties: { [key: string]: Schema };
allOf?: IDependency[];
required: string[];
}
@@ -156,6 +156,32 @@ describe('credentials.utils', () => {
);
});
describe('typed fields', () => {
const credentialsHelper = credentialsHelperWithProperties([
{ name: 'port', type: 'number', displayName: 'Port', default: 21 },
{ name: 'secure', type: 'boolean', displayName: 'Secure', default: false },
...CONDITIONAL_PROPERTIES,
]);
it.each([
['number', { port: '={{ $vars.PORT }}' }],
['boolean', { secure: '={{ $vars.SECURE }}' }],
['options', { authType: '={{ $vars.AUTH_TYPE }}' }],
])('accepts an expression in a %s field', (_, data) => {
expect(() => validateCredentialData(credentialsHelper, 'someType', data)).not.toThrow();
});
it.each([
['number', { port: '21' }],
['boolean', { secure: 'true' }],
['options', { authType: 'other' }],
])('rejects a non-expression value of the wrong type in a %s field', (_, data) => {
expect(() => validateCredentialData(credentialsHelper, 'someType', data)).toThrow(
/request\.body\.data/,
);
});
});
it('enforces conditionally-required fields when partialData is not set', () => {
const credentialsHelper = credentialsHelperWithProperties(CONDITIONAL_PROPERTIES);
@@ -5,6 +5,7 @@ import {
type DisplayCondition,
type INodeProperties,
type INodePropertyOptions,
isExpression,
} from 'n8n-workflow';
import type { CredentialsHelper } from '@/credentials-helper';
@@ -72,6 +73,13 @@ export function validateCredentialData(
delete schema.allOf;
}
// An expression resolves at runtime, so validate it only as a string.
for (const [name, value] of Object.entries(data)) {
if (isExpression(value) && Object.hasOwn(schema.properties, name)) {
schema.properties[name] = { type: 'string' };
}
}
const { valid, errors } = validate(data, schema, { nestedErrors: true });
if (!valid) {
@@ -35,6 +35,7 @@ requestBody:
data:
type: object
additionalProperties: {}
description: The credential data. Fields also accept n8n expressions, which are strings that start with "=".
writeOnly: true
example:
accessToken: ada612vad6fa5df4adf5a5dsf4389adsf76da7s

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